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 +241 -37 1.32.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
@@ -43,8 +45,29 @@
43 45 */
44 46 private const PRUNE_HOOK = 'thinkrank_ai_traffic_prune';
45 47
46 48 /**
49 + * Object-cache group for the buffered hit counters.
50 + */
51 + private const COUNTER_GROUP = 'thinkrank_traffic';
52 +
53 + /**
54 + * Key prefix for those counters.
55 + */
56 + private const COUNTER_PREFIX = 'tr_traffic_';
57 +
58 + /**
59 + * Flush a bucket once it has this many buffered hits.
60 + */
61 + private const FLUSH_AT = 50;
62 +
63 + /**
64 + * ...or once its oldest buffered hit is this many seconds old, so a quiet
65 + * site still records its traffic.
66 + */
67 + private const FLUSH_AFTER = 300;
68 +
69 + /**
47 70 * Days of history to keep. The dashboard reads 30; keep 6 months so a
48 71 * longer range is possible later without changing collection.
49 72 */
50 73 private const RETENTION_DAYS = 180;
@@ -74,36 +97,8 @@
74 97 'kimi.com' => 'kimi',
75 98 ];
76 99
77 100 /**
78 - * User-agent fragments → AI crawler slug. Case-insensitive substring
79 - * match. Order matters where one token contains another — more specific
80 - * entries first.
81 - *
82 - * @var array<string, string>
83 - */
84 - private const CRAWLER_AGENTS = [
85 - 'OAI-SearchBot' => 'oai-searchbot',
86 - 'ChatGPT-User' => 'chatgpt-user',
87 - 'GPTBot' => 'gptbot',
88 - 'Perplexity-User' => 'perplexity-user',
89 - 'PerplexityBot' => 'perplexitybot',
90 - 'Claude-SearchBot' => 'claude-searchbot',
91 - 'Claude-User' => 'claude-user',
92 - 'ClaudeBot' => 'claudebot',
93 - 'anthropic-ai' => 'anthropic-ai',
94 - 'Google-Extended' => 'google-extended',
95 - 'Applebot-Extended' => 'applebot-extended',
96 - 'meta-externalagent' => 'meta-externalagent',
97 - 'meta-externalfetcher' => 'meta-externalfetcher',
98 - 'Bytespider' => 'bytespider',
99 - 'Amazonbot' => 'amazonbot',
100 - 'CCBot' => 'ccbot',
101 - 'cohere-ai' => 'cohere-ai',
102 - 'MistralAI-User' => 'mistral-user',
103 - ];
104 -
105 - /**
106 101 * Wire the front-end recorder and the retention cron.
107 102 *
108 103 * @return void
109 104 */
@@ -146,8 +141,12 @@
146 141
147 142 /**
148 143 * Classify a user agent as an AI crawler.
149 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 + *
150 149 * @param string $user_agent Raw user agent (may be empty).
151 150 * @return string|null Crawler slug, or null when not a known AI crawler.
152 151 */
153 152 public static function classify_crawler(string $user_agent): ?string {
@@ -154,9 +153,12 @@
154 153 if ('' === $user_agent) {
155 154 return null;
156 155 }
157 156
158 - 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) {
159 161 if (false !== stripos($user_agent, $fragment)) {
160 162 return $slug;
161 163 }
162 164 }
@@ -236,9 +238,11 @@
236 238 global $wpdb;
237 239
238 240 $days = max(1, min(self::RETENTION_DAYS, $days));
239 241 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
240 - $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);
241 245
242 246 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only aggregate over our own table.
243 247 return (int) $wpdb->get_var(
244 248 $wpdb->prepare(
@@ -271,23 +275,90 @@
271 275 * @param string $path Landing path (referrals only).
272 276 * @return void
273 277 */
274 278 private function bump(string $kind, string $source, string $path = ''): void {
279 + // Without a persistent object cache there is nowhere to buffer, so keep
280 + // the direct write rather than counting into per-request memory that is
281 + // thrown away — that would lose hits outright.
282 + if (!wp_using_ext_object_cache()) {
283 + $this->write_bucket($kind, $source, $path, 1);
284 +
285 + return;
286 + }
287 +
288 + // With one, buffer and flush in batches. The unique key is
289 + // (day, kind, source, path), so all baseline traffic funnels into a
290 + // single row per day: InnoDB took an exclusive row lock on it for every
291 + // visitor, serialising concurrent anonymous traffic, and made every
292 + // pageview a write even when the response was fully cacheable (#402).
293 + $bucket = self::COUNTER_PREFIX . md5($kind . '|' . $source . '|' . $path);
294 + $since = $bucket . '_since';
295 +
296 + $hits = wp_cache_incr($bucket, 1, self::COUNTER_GROUP);
297 +
298 + if (false === $hits) {
299 + wp_cache_add($bucket, 1, self::COUNTER_GROUP, 0);
300 + wp_cache_add($since, time(), self::COUNTER_GROUP, 0);
301 + $hits = 1;
302 + }
303 +
304 + $started = (int) wp_cache_get($since, self::COUNTER_GROUP);
305 +
306 + // Flush on either bound, so a busy site writes rarely and a quiet one
307 + // still lands its hits — an eviction can cost at most one window.
308 + if ($hits < self::FLUSH_AT && $started > 0 && (time() - $started) < self::FLUSH_AFTER) {
309 + return;
310 + }
311 +
312 + wp_cache_set($bucket, 0, self::COUNTER_GROUP, 0);
313 + wp_cache_set($since, time(), self::COUNTER_GROUP, 0);
314 +
315 + $this->write_bucket($kind, $source, $path, (int) $hits);
316 + }
317 +
318 + /**
319 + * Add hits to a bucket's row.
320 + *
321 + * @since 2.0.1
322 + *
323 + * @param string $kind 'referral' | 'crawler' | 'baseline'.
324 + * @param string $source Platform/bot slug, or 'all' for baseline.
325 + * @param string $path Landing path (referrals only).
326 + * @param int $hits How many hits to add.
327 + * @return void
328 + */
329 + private function write_bucket(string $kind, string $source, string $path, int $hits): void {
330 + if ($hits < 1) {
331 + return;
332 + }
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 +
275 344 global $wpdb;
276 345
277 346 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
278 347
279 - // Single cheap upsert per pageview; the unique key is the bucket.
348 + // Aggregate counter upsert; the unique key is the bucket.
280 349 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- aggregate counter upsert; table name is prefix-derived.
281 350 $wpdb->query(
282 351 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
283 352 $wpdb->prepare(
284 - "INSERT INTO {$table} (day, kind, source, path, hits) VALUES (%s, %s, %s, %s, 1)
285 - ON DUPLICATE KEY UPDATE hits = hits + 1",
353 + "INSERT INTO {$table} (day, kind, source, path, hits) VALUES (%s, %s, %s, %s, %d)
354 + ON DUPLICATE KEY UPDATE hits = hits + %d",
286 355 current_time('Y-m-d'),
287 356 $kind,
288 357 $source,
289 - $path
358 + $path,
359 + $hits,
360 + $hits
290 361 )
291 362 );
292 363 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
293 364 }
@@ -292,8 +363,83 @@
292 363 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
293 364 }
294 365
295 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 + /**
296 442 * Dashboard summary for the last N days.
297 443 *
298 444 * @param int $days Range in days (bounded 1–180).
299 445 * @return array<string, mixed>
@@ -302,9 +448,11 @@
302 448 global $wpdb;
303 449
304 450 $days = max(1, min(self::RETENTION_DAYS, $days));
305 451 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
306 - $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);
307 455
308 456 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only aggregates over our own table.
309 457 $rows = $wpdb->get_results(
310 458 $wpdb->prepare(
@@ -350,8 +498,31 @@
350 498 arsort($pages);
351 499 arsort($crawlers);
352 500 ksort($trend);
353 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 +
354 525 return [
355 526 'days' => $days,
356 527 'baseline' => $baseline,
357 528 'ai_sessions' => $referrals,
@@ -361,9 +532,14 @@
361 532 'top_pages' => array_slice($pages, 0, 10, true),
362 533 'crawlers' => $crawlers,
363 534 // Whether llms.txt is being served, so the crawler panel can pair
364 535 // "bots are coming" with "and here's what we feed them".
365 - '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(),
366 542 // Pages served as Markdown by Pro's Markdown for AI feature
367 543 // (kind 'markdown', written via record_served_markdown()).
368 544 'markdown_served' => $markdown,
369 545 ];
@@ -369,8 +545,35 @@
369 545 ];
370 546 }
371 547
372 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 + /**
373 576 * Drop aggregate rows past the retention window.
374 577 *
375 578 * @return void
376 579 */
@@ -377,9 +580,10 @@
377 580 public function prune(): void {
378 581 global $wpdb;
379 582
380 583 $table = $wpdb->prefix . 'thinkrank_ai_traffic';
381 - $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);
382 586
383 587 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- retention delete on our own table.
384 588 $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE day < %s", $cutoff));
385 589 }