settings = $settings; $this->client = $client ? $client : new Client(); } /** * Hooks only — no work in the constructor. * * @return void */ public function register() { Table::maybe_install(); add_action( self::CRON, array( $this, 'poll' ) ); // Reconcile the schedule from the admin, where a human just changed the // connection — cheap (one wp_next_scheduled read) and self-healing. add_action( 'admin_init', array( $this, 'sync_schedule' ) ); } /** * Keep the cron slot consistent with the connection state: scheduled while * connected, absent while not. No-ops on the already-correct common case. * * @return void */ public function sync_schedule() { $scheduled = (bool) wp_next_scheduled( self::CRON ); if ( ! $this->settings->connected() ) { if ( $scheduled ) { wp_clear_scheduled_hook( self::CRON ); } return; } if ( ! $scheduled ) { wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', self::CRON ); } } /** * Activation-time scheduling: only when a connection already exists (an * upgrade or a reactivation) — a fresh install schedules nothing until the * owner connects. * * @return void */ public static function schedule() { if ( ! ( new Settings() )->connected() ) { return; } if ( ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', self::CRON ); } } /** * Deactivation cleanup. * * @return void */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON ); delete_option( self::LOCK_OPTION ); } /** * The cron handler: fetch the window since the last stored hour, aggregate * per crawler, store, prune. Never overlaps itself. * * @return void */ public function poll() { if ( ! $this->settings->connected() ) { return; } if ( ! self::acquire_lock() ) { return; } try { $this->run_poll(); } finally { self::release_lock(); } } /** * One poll, also callable inline right after connect so the panel has * numbers immediately instead of "come back in an hour". * * @return void */ public function run_poll() { $token = $this->settings->token(); $zone = (string) $this->settings->get( 'zone_id', '' ); if ( '' === $token || '' === $zone ) { return; } $now = time(); $latest = Table::latest_hour(); $since = $latest > 0 ? $latest - self::OVERLAP_SECONDS : $now - self::BACKFILL_SECONDS; $since = max( $since, $now - self::BACKFILL_SECONDS ); while ( $since < $now ) { $until = min( $since + self::CHUNK_SECONDS, $now ); $out = $this->client->hourly_traffic( $token, $zone, $since, $until ); if ( isset( $out['error'] ) ) { // Keep the last good data; the panel shows the numbers' age and this note. $this->settings->record_poll( (string) $out['error'] ); return; } Table::upsert( self::aggregate( (array) $out['rows'] ) ); $since = $until; } $this->settings->record_poll( '' ); Table::prune(); } /** * Collapse Cloudflare's raw (hour, UA, cache, status) rows into one row per * (hour, known AI crawler). Unknown user agents are dropped — this panel is * about AI crawlers, and the Catalog is the same registry the request log * uses, so names line up across screens. * * Each request lands in exactly one bucket: * - blocked : the edge answered 403/429 without ever contacting the origin * (a challenge or block — the origin never saw it); * - cached : served from Cloudflare's cache; * - origin : everything else — the request reached this server. * * @param array $raw Rows from Client::hourly_traffic(). * @return array Rows shaped for Table::upsert(). */ public static function aggregate( array $raw ) { $cached_statuses = array( 'hit', 'stale', 'updating', 'revalidated' ); $buckets = array(); foreach ( $raw as $row ) { $token = self::crawler_token( (string) $row['ua'] ); if ( '' === $token ) { continue; } $hour = self::hour_key( (string) $row['hour'] ); if ( '' === $hour ) { continue; } $key = $hour . '|' . $token; if ( ! isset( $buckets[ $key ] ) ) { $buckets[ $key ] = array( 'hour_at' => $hour, 'ua' => $token, 'requests' => 0, 'cached' => 0, 'origin' => 0, 'blocked' => 0, 'bytes' => 0, ); } $n = (int) $row['requests']; $buckets[ $key ]['requests'] += $n; $buckets[ $key ]['bytes'] += (int) $row['bytes']; $edge_refused = in_array( (int) $row['edge_status'], array( 403, 429 ), true ) && 0 === (int) $row['origin_status']; if ( $edge_refused ) { $buckets[ $key ]['blocked'] += $n; } elseif ( in_array( (string) $row['cache_status'], $cached_statuses, true ) ) { $buckets[ $key ]['cached'] += $n; } else { $buckets[ $key ]['origin'] += $n; } } return array_values( $buckets ); } /** * Match a raw User-Agent to a Catalog token — the same substring rule * Catalog::identify() applies, but returning the stable token we store. * Only AI-kind crawlers count; a search or SEO bot returns ''. * * @param string $ua Raw User-Agent. * @return string The catalog token, or '' for no (AI) match. */ public static function crawler_token( $ua ) { $ua_lc = strtolower( (string) $ua ); if ( '' === $ua_lc ) { return ''; } foreach ( Catalog::known() as $token => $row ) { if ( false !== strpos( $ua_lc, (string) $token ) ) { return 'ai' === (string) $row[2] ? (string) $token : ''; } } return ''; } /** * Normalize Cloudflare's datetimeHour (RFC3339, UTC) into the table's * datetime format. '' when unparseable. * * @param string $hour e.g. "2026-07-30T14:00:00Z". * @return string e.g. "2026-07-30 14:00:00". */ private static function hour_key( $hour ) { $ts = strtotime( $hour ); return $ts ? gmdate( 'Y-m-d H:00:00', $ts ) : ''; } /** * Take the run lock, stealing a stale one (a died job must not wedge the * poll forever). * * @return bool Whether this caller holds the lock. */ private static function acquire_lock() { $held = (int) get_option( self::LOCK_OPTION, 0 ); if ( $held > 0 && ( time() - $held ) < self::LOCK_TTL ) { return false; } update_option( self::LOCK_OPTION, time(), false ); return true; } /** * Release the run lock. * * @return void */ private static function release_lock() { delete_option( self::LOCK_OPTION ); } }