PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-ai-traffic-tracker.php +159 -33 2.1.0 → 2.9.0 View file →
@@ -28,8 +28,10 @@
28 28 declare(strict_types=1);
29 29
30 30 namespace ThinkRank\SEO;
31 31
32 +use DateTimeImmutable;
33 +
32 34 if (!defined('ABSPATH')) {
33 35 exit;
34 36 }
35 37
@@ -95,36 +97,8 @@
95 97 'kimi.com' => 'kimi',
96 98 ];
97 99
98 100 /**
99 - * User-agent fragments → AI crawler slug. Case-insensitive substring
100 - * match. Order matters where one token contains another — more specific
101 - * entries first.
102 - *
103 - * @var array<string, string>
104 - */
105 - private const CRAWLER_AGENTS = [
106 - 'OAI-SearchBot' => 'oai-searchbot',
107 - 'ChatGPT-User' => 'chatgpt-user',
108 - 'GPTBot' => 'gptbot',
109 - 'Perplexity-User' => 'perplexity-user',
110 - 'PerplexityBot' => 'perplexitybot',
111 - 'Claude-SearchBot' => 'claude-searchbot',
112 - 'Claude-User' => 'claude-user',
113 - 'ClaudeBot' => 'claudebot',
114 - 'anthropic-ai' => 'anthropic-ai',
115 - 'Google-Extended' => 'google-extended',
116 - 'Applebot-Extended' => 'applebot-extended',
117 - 'meta-externalagent' => 'meta-externalagent',
118 - 'meta-externalfetcher' => 'meta-externalfetcher',
119 - 'Bytespider' => 'bytespider',
120 - 'Amazonbot' => 'amazonbot',
121 - 'CCBot' => 'ccbot',
122 - 'cohere-ai' => 'cohere-ai',
123 - 'MistralAI-User' => 'mistral-user',
124 - ];
125 -
126 - /**
127 101 * Wire the front-end recorder and the retention cron.
128 102 *
129 103 * @return void
130 104 */
@@ -167,8 +141,12 @@
167 141
168 142 /**
169 143 * Classify a user agent as an AI crawler.
170 144 *
145 + * The agent list is `AI_Crawlers`, shared with the robots.txt panel — the
146 + * two must agree about which bots exist, or a site blocks a crawler it is
147 + * not counting (#657).
148 + *
171 149 * @param string $user_agent Raw user agent (may be empty).
172 150 * @return string|null Crawler slug, or null when not a known AI crawler.
173 151 */
174 152 public static function classify_crawler(string $user_agent): ?string {
@@ -175,9 +153,12 @@
175 153 if ('' === $user_agent) {
176 154 return null;
177 155 }
178 156
179 - foreach (self::CRAWLER_AGENTS as $fragment => $slug) {
157 + // Token order is significant and owned by the registry: the first
158 + // token found wins, so `Claude-SearchBot` has to be tested before
159 + // `ClaudeBot`. See AI_Crawlers::AGENTS.
160 + foreach (AI_Crawlers::token_map() as $fragment => $slug) {
180 161 if (false !== stripos($user_agent, $fragment)) {
181 162 return $slug;
182 163 }
183 164 }
@@ -257,9 +238,11 @@
257 238 global $wpdb;
258 239
259 240 $days = max(1, min(self::RETENTION_DAYS, $days));
260 241 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
261 - $since = gmdate('Y-m-d', time() - $days * DAY_IN_SECONDS);
242 + // Same clock as write_bucket(), and counted in calendar days so a
243 + // DST transition inside the window does not move the boundary.
244 + $since = $this->day_key_offset($days);
262 245
263 246 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only aggregate over our own table.
264 247 return (int) $wpdb->get_var(
265 248 $wpdb->prepare(
@@ -347,8 +330,18 @@
347 330 if ($hits < 1) {
348 331 return;
349 332 }
350 333
334 + // `day` is the SITE-LOCAL date (see day_key()), not UTC. The column is
335 + // a bare `date` with no zone attached, so the clock that writes it is
336 + // the only thing that gives it meaning — and these keys reach the user
337 + // as the trend chart's dates, where the site's own calendar is what
338 + // they expect to read.
339 + //
340 + // Every range boundary and retention cutoff must be derived with
341 + // day_key() too. A gmdate() boundary against these rows drifts by a
342 + // day for part of every day on a non-UTC site.
343 +
351 344 global $wpdb;
352 345
353 346 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
354 347
@@ -370,8 +363,83 @@
370 363 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
371 364 }
372 365
373 366 /**
367 + * The site-local date key for an instant, matching write_bucket().
368 + *
369 + * Every consumer of the `day` column goes through this, so the read side
370 + * cannot drift onto a different calendar from the write side.
371 + *
372 + * @param int|null $timestamp Unix timestamp, or null for now.
373 + * @return string `Y-m-d` on the site's clock.
374 + */
375 + private function day_key(?int $timestamp = null): string {
376 + return wp_date('Y-m-d', $timestamp ?? time());
377 + }
378 +
379 + /**
380 + * Midday on a given site-local date.
381 + *
382 + * Midday, not midnight: a handful of zones start DST at 00:00, so
383 + * midnight on a transition date can be a time that does not exist and
384 + * PHP quietly rolls it forward. Noon is never inside a DST gap, so
385 + * every date in the year is representable.
386 + *
387 + * @param string $day `Y-m-d` on the site's clock.
388 + * @return DateTimeImmutable
389 + */
390 + private function local_noon(string $day): DateTimeImmutable {
391 + return new DateTimeImmutable($day . ' 12:00:00', wp_timezone());
392 + }
393 +
394 + /**
395 + * The site-local date key N *calendar* days before today.
396 + *
397 + * Not `time() - N * DAY_IN_SECONDS`: a fixed 86400-second step is not a
398 + * day on a clock that shifts. Around a DST transition that arithmetic
399 + * lands an hour early or late, which moves the date for the hour either
400 + * side of midnight.
401 + *
402 + * @param int $days_ago Whole days back.
403 + * @return string `Y-m-d`.
404 + */
405 + private function day_key_offset(int $days_ago): string {
406 + return $this->local_noon($this->day_key())
407 + ->modify('-' . max(0, $days_ago) . ' day')
408 + ->format('Y-m-d');
409 + }
410 +
411 + /**
412 + * Every site-local date from $from to $to inclusive.
413 + *
414 + * Walks the calendar rather than stepping by 86400 seconds, so a DST
415 + * transition inside the range neither duplicates a date nor skips one.
416 + * Skipping one used to drop that day's referrals out of the trend while
417 + * they stayed in the totals.
418 + *
419 + * @param string $from `Y-m-d`, inclusive.
420 + * @param string $to `Y-m-d`, inclusive.
421 + * @return string[] Ordered, contiguous date keys.
422 + */
423 + private function day_range(string $from, string $to): array {
424 + $cursor = $this->local_noon($from);
425 + $end = $this->local_noon($to);
426 +
427 + $days = [];
428 + // Bounded by the caller's window (<= RETENTION_DAYS), with headroom
429 + // so a malformed pair can never spin here.
430 + $guard = self::RETENTION_DAYS + 2;
431 + $steps = 0;
432 + while ($cursor <= $end && $steps < $guard) {
433 + $days[] = $cursor->format('Y-m-d');
434 + $cursor = $cursor->modify('+1 day');
435 + $steps++;
436 + }
437 +
438 + return $days;
439 + }
440 +
441 + /**
374 442 * Dashboard summary for the last N days.
375 443 *
376 444 * @param int $days Range in days (bounded 1–180).
377 445 * @return array<string, mixed>
@@ -380,9 +448,11 @@
380 448 global $wpdb;
381 449
382 450 $days = max(1, min(self::RETENTION_DAYS, $days));
383 451 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
384 - $since = gmdate('Y-m-d', time() - $days * DAY_IN_SECONDS);
452 + // Same clock as write_bucket(), and counted in calendar days so a
453 + // DST transition inside the window does not move the boundary.
454 + $since = $this->day_key_offset($days);
385 455
386 456 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only aggregates over our own table.
387 457 $rows = $wpdb->get_results(
388 458 $wpdb->prepare(
@@ -428,8 +498,31 @@
428 498 arsort($pages);
429 499 arsort($crawlers);
430 500 ksort($trend);
431 501
502 + // Fill every day the query covered, zeroes included. Only days that
503 + // had a referral produce a $trend key above, and the chart positions
504 + // points by index — so a sparse map drew a three-week gap exactly
505 + // like a one-day gap. A contiguous series makes even spacing correct,
506 + // and distinguishes "no referrals that day" from "no data".
507 + //
508 + // The range mirrors the WHERE clause (day >= $since, through today)
509 + // so the series covers exactly what was counted, and it is built on
510 + // day_key() so the keys match how the rows were written.
511 + $filled = [];
512 + foreach ($this->day_range($since, $this->day_key()) as $day) {
513 + $filled[$day] = $trend[$day] ?? 0;
514 + }
515 +
516 + // Safety net for anything the window did not cover — a row dated
517 + // ahead of today, which a site that moved timezone can hold. Union
518 + // keeps the filled zeroes and adds only keys not already present, so
519 + // the series can never total less than ai_sessions.
520 + $filled += $trend;
521 + ksort($filled);
522 +
523 + $trend = $filled;
524 +
432 525 return [
433 526 'days' => $days,
434 527 'baseline' => $baseline,
435 528 'ai_sessions' => $referrals,
@@ -439,9 +532,14 @@
439 532 'top_pages' => array_slice($pages, 0, 10, true),
440 533 'crawlers' => $crawlers,
441 534 // Whether llms.txt is being served, so the crawler panel can pair
442 535 // "bots are coming" with "and here's what we feed them".
443 - 'llms_txt' => file_exists(ABSPATH . 'llms.txt'),
536 + //
537 + // Ask the manager, not the filesystem: `dynamic` delivery — the
538 + // resolved default on every non-Apache stack — publishes no
539 + // physical file and answers from serve_llms_txt(), so a
540 + // file_exists() probe reports "not published" for a live document.
541 + 'llms_txt' => $this->llms_txt_published(),
444 542 // Pages served as Markdown by Pro's Markdown for AI feature
445 543 // (kind 'markdown', written via record_served_markdown()).
446 544 'markdown_served' => $markdown,
447 545 ];
@@ -447,8 +545,35 @@
447 545 ];
448 546 }
449 547
450 548 /**
549 + * Whether llms.txt is currently being served, in either delivery mode.
550 + *
551 + * `static` publishes a file at ABSPATH; `dynamic` keeps the document in
552 + * an option and serves it from a PHP route. Only the manager knows which
553 + * is in force, so it is the single source of truth here.
554 + *
555 + * @return bool
556 + */
557 + private function llms_txt_published(): bool {
558 + // Spelt exactly as the class is declared. The autoloader routes this
559 + // one through a case-SENSITIVE special-case map, and while a
560 + // mis-cased name happens to fall through to the generic rule and
561 + // resolve anyway, that is a coincidence — a change to that rule would
562 + // silently make class_exists() false here, and the badge would go
563 + // back to reporting "No llms.txt" for a live document.
564 + if (!class_exists(LLMs_Txt_Manager::class)) {
565 + // Defensive: a partial load must not claim llms.txt is live.
566 + return false;
567 + }
568 +
569 + // is_published(), not get_llms_txt_status(): the latter resolves the
570 + // delivery mode, may fire a loopback probe and touches the filesystem
571 + // API, which is far too much work for a dashboard boolean.
572 + return (new LLMs_Txt_Manager())->is_published();
573 + }
574 +
575 + /**
451 576 * Drop aggregate rows past the retention window.
452 577 *
453 578 * @return void
454 579 */
@@ -455,9 +580,10 @@
455 580 public function prune(): void {
456 581 global $wpdb;
457 582
458 583 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
459 - $cutoff = gmdate('Y-m-d', time() - self::RETENTION_DAYS * DAY_IN_SECONDS);
584 + // Same clock as write_bucket(), counted in calendar days.
585 + $cutoff = $this->day_key_offset(self::RETENTION_DAYS);
460 586
461 587 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- retention delete on our own table.
462 588 $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE day < %s", $cutoff));
463 589 }