| @@ -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 | } |