settings = $settings; $this->client = $client ? $client : new Client(); } /** * Hooks only — no work in the constructor. * * @return void */ public function register() { Search\Table::maybe_install(); add_action( self::CRON, array( $this, 'poll' ) ); add_action( self::CRON_INDEX, array( $this, 'index_chunk' ) ); add_action( 'admin_init', array( $this, 'sync_schedule' ) ); } /** * Keep the cron slot consistent with the connection state. * * @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, 'daily', self::CRON ); } } /** * Activation-time scheduling: only when a connection already exists. * * @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, 'daily', self::CRON ); } } /** * Deactivation cleanup. * * @return void */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON ); wp_clear_scheduled_hook( self::CRON_INDEX ); delete_option( self::LOCK_OPTION ); } /** * The cron handler. Never overlaps itself. * * @return void */ public function poll() { if ( ! $this->settings->connected() ) { return; } if ( ! self::acquire_lock() ) { return; } try { $this->run_poll(); // The index sweep rides the same daily slot but keeps its own // bookkeeping: a spent inspection quota must not smear "poll // failed" over a perfectly good performance snapshot. One chunk // here; the continuation event carries whatever remains. // // A run the owner stopped yesterday is picked up here, because that // is exactly what the card told them would happen: "press Check Now // to finish, or the daily check picks them up". A pause stops the // machinery from running away with a run; it is not a decision to // never check those pages again. Index::resume(); $this->run_index_sweep( 15.0 ); } finally { self::release_lock(); } } /** * One poll, also callable inline right after connect so the screen has * numbers immediately. The report covers the trailing window ending where * Search Console's numbers are final; each poll replaces the snapshot. * * ⛔ The analytics half runs on EVERY way out of the search half. It is a * second, independent grant, and the search half returns early on any * Search Console refusal — which once froze the Visitors numbers, with no * error on them, whenever the key lost its Search Console access (found * in the 2026-10-23 audit). * * @return void */ public function run_poll() { try { $this->run_search_poll(); } finally { $this->poll_analytics(); } } /** * The Search Console half of {@see run_poll()}. * * @return void */ private function run_search_poll() { $sa_json = $this->settings->sa_json(); $property = (string) $this->settings->get( 'property', '' ); if ( '' === $sa_json || '' === $property ) { return; } $auth = Auth::token( $sa_json ); if ( isset( $auth['error'] ) ) { $this->settings->record_poll( (string) $auth['error'] ); return; } $end = gmdate( 'Y-m-d', time() - self::LAG_DAYS * DAY_IN_SECONDS ); $start = gmdate( 'Y-m-d', time() - ( self::LAG_DAYS + self::WINDOW_DAYS ) * DAY_IN_SECONDS ); $out = $this->client->search_analytics( $auth['token'], $property, $start, $end ); if ( isset( $out['error'] ) ) { $this->settings->record_poll( (string) $out['error'] ); return; } $rows = self::map_rows( (array) $out['rows'], $start, $end ); // The decoded API response is dead once the rows are mapped, and on a big // property it is the largest single thing in memory. Dropped before the // write rather than at the end of the function, so the insert never runs // alongside a copy nothing will read again. unset( $out ); $saved = Search\Table::replace( 'google', $rows, Search\Table::TYPE_WEB ); unset( $rows ); // Written on every poll, clean or not: a poll that fitted has to clear a // previous poll's number, or the screen keeps apologising for something // that stopped being true. $this->settings->record_dropped( $saved['dropped'] ); if ( ! $saved['ok'] ) { // ⚠️ Checked on `ok`, never on a count. A part-written snapshot used to // return a positive number and pass as a clean poll, and every screen // then read the survivors as the whole truth. // // The wording is careful for the same reason: replace() throws a // half-written set away and leaves the previous one standing, so what // is true here is "the new numbers could not be saved" — NOT "your // numbers are gone". Telling an owner their data vanished when it did // not is its own kind of lie. $this->settings->record_poll( __( 'The new search numbers could not be saved. The ones already here are still correct and still shown. This usually happens when the report is bigger than one database write allows.', 'agentimus' ) ); } else { $this->settings->record_poll( '' ); // A fresh snapshot is the only moment new evidence exists about // whether a worklist page climbed — the table it replaced is the // version that would have been compared against. So the ledger is // brought up to date here, and from no other path. if ( $saved['written'] > 0 ) { // ⚠️ FULLY QUALIFIED, and deliberately so: inside namespace // Agentimus\Google a bare `Settings` is Google\Settings — the very // class that caused this bug. Progress wants the plugin's CORE // settings (the set-aside lists); handing it a connection store // threw a TypeError that WordPress showed as "there has been a // critical error". // // On this side the damage was quieter: the fatal lands AFTER // record_poll( '' ), so the snapshot saved and everything below it — // the trend series, the Discover totals, the analytics poll — simply // never ran. The card reported "recent daily polls couldn't update // it" for three days and nobody knew why. Search\Progress::observe( 'google', new \Agentimus\Settings() ); } } // The other surfaces. Search Console counts the Image, Video and News // tabs separately from the "All" tab read above, so a page whose readers // arrive from image search reads as quiet until they are asked for by // name. Deliberately AFTER the poll has been recorded: these are extra, // and one of them failing must never turn a good poll into a bad one. $this->poll_extra_surfaces( (string) $auth['token'], $property, $start, $end ); // The trend series and Discover totals ride the same poll — two cheap // extra calls. Their failures never smear the snapshot above: the // window numbers just landed, and a transport blip on the extras // leaves the last good series in place rather than a false zero. $trend = get_option( self::TREND_OPTION, array() ); $trend = is_array( $trend ) ? $trend : array(); $daily = $this->client->search_analytics_daily( $auth['token'], $property, $start, $end ); if ( isset( $daily['days'] ) ) { $trend['daily'] = self::merge_daily( isset( $trend['daily'] ) && is_array( $trend['daily'] ) ? $trend['daily'] : array(), $daily['days'], self::TREND_KEEP ); // Stamped on SUCCESS only: keeping last-good data on a blip is // right, but a trend quietly stale for a week must be able to // say so on the screen instead of posing as current. $trend['daily_at'] = time(); } $discover = $this->client->discover_totals( $auth['token'], $property, $start, $end ); if ( isset( $discover['totals'] ) ) { $trend['discover'] = $discover['totals']; } update_option( self::TREND_OPTION, $trend, false ); } /** * @var array The surfaces read beyond the "All" tab. * * ⛔ `discover` is not here and never will be: it has no query at all, so a * row of it in a table keyed by query would be a lie about its own shape. Its * totals ride the trend option instead. * ⛔ `googleNews` is not here either — it is the Google News site and app, * a different product from the News tab, and almost no owner publishes to it. * An always-empty line invites a question that has no answer. One entry in * this list is all it would take to add. */ const EXTRA_SURFACES = array( 'image', 'video', 'news' ); /** * Shape one surface's API rows for the store. * * @param array $api_rows Rows as Search Console returned them. * @param string $start Window start, Y-m-d. * @param string $end Window end, Y-m-d. * @return array */ private static function map_rows( array $api_rows, $start, $end ) { $rows = array(); foreach ( $api_rows as $row ) { $rows[] = array( 'query' => $row['query'], 'page_url' => $row['page'], // Memoized per request, so the surfaces after the first cost // nothing for pages the "All" tab already resolved. 'page_id' => Search\Pages::resolve( $row['page'] ), 'clicks' => $row['clicks'], 'impressions' => $row['impressions'], 'position' => $row['position'], 'range_start' => $start, 'range_end' => $end, ); } return $rows; } /** * Read the Image, Video and News tabs, each into its own set of rows. * * Kept apart from the main poll on purpose. These are additional surfaces: * one of them failing leaves its own previous rows standing and says so, and * never marks the connection broken — the numbers an owner mostly looks at * came back fine. ⚠️ Every outcome is written down, including the good ones, * so "no image searches" and "we could not ask about image searches" are * never the same empty space on a screen. * * @param string $token Bearer token. * @param string $property The GSC property. * @param string $start Window start, Y-m-d. * @param string $end Window end, Y-m-d. * @return void */ private function poll_extra_surfaces( $token, $property, $start, $end ) { $state = array(); foreach ( self::EXTRA_SURFACES as $surface ) { $out = $this->client->search_analytics( $token, $property, $start, $end, $surface ); if ( isset( $out['error'] ) ) { $state[ $surface ] = (string) $out['error']; continue; } $rows = self::map_rows( (array) $out['rows'], $start, $end ); unset( $out ); $saved = Search\Table::replace( 'google', $rows, $surface ); unset( $rows ); $state[ $surface ] = $saved['ok'] ? '' : __( 'These numbers could not be saved this time. The ones already here are still shown.', 'agentimus' ); } $this->settings->record_surface_state( $state ); } /** * The GA4 half alone, for the moment Analytics is connected — the same * first-numbers-now courtesy the key connect and the Bing connect give. * Without it, a Visitors screen that just verified successfully sits empty * until tomorrow's cron, looking broken to the owner who connected it. * * @return void */ public function run_analytics_poll() { $this->poll_analytics(); } /** * The GA4 half of the poll — everyone who came, not only the ones an engine * or an assistant sent. * * Runs LAST and never touches the Search Console outcome above: analytics is * a second, independent grant, and a property the key can't read must leave * the search snapshot exactly as it found it. Its own error field, its own * timestamp, its own silence. * * A SHORT window (the dashboard's own report span, not Search Console's 56 * days): this number sits beside counts from the request log, and two figures * side by side must cover the same days or the comparison is a lie. * * @return void */ private function poll_analytics() { if ( ! $this->settings->analytics_connected() ) { return; } $property = (string) $this->settings->get( 'ga4_property', '' ); $auth = Auth::token( $this->settings->sa_json(), Auth::SCOPE_ANALYTICS ); if ( isset( $auth['error'] ) ) { $this->settings->record_ga4_poll( (string) $auth['error'] ); return; } $window = \Agentimus\Activity\Repository::report_days(); $end = gmdate( 'Y-m-d' ); $start = gmdate( 'Y-m-d', time() - ( max( 1, $window ) - 1 ) * DAY_IN_SECONDS ); $client = new Analytics(); $out = $client->totals( $auth['token'], $property, $start, $end ); if ( isset( $out['error'] ) ) { // Last good numbers stay put. A transport blip must not turn into a // confident zero on a card that says how many people read the site. $this->settings->record_ga4_poll( (string) $out['error'] ); return; } // GA4's own reading of how many of those readers an assistant sent — the // second opinion the Visitors screen shows beside our own count. A failure // here costs only the split: the totals above are already good, and // throwing them away over a secondary report would be a poor trade. $split = $client->ai_split( $auth['token'], $property, $start, $end ); // The busiest pages for EVERYONE — the human half's equivalent of the // AI landing-page list, and the thing that makes that list mean // something (a page with 8 AI visits reads differently when the page // has 40 in total than when it has 9). $pages = $client->top_pages( $auth['token'], $property, $start, $end, 8 ); update_option( self::GA4_OPTION, array( 'totals' => $out['totals'], 'split' => isset( $split['split'] ) ? $split['split'] : null, 'pages' => isset( $pages['pages'] ) ? $pages['pages'] : array(), 'window' => (int) $window, 'start' => $start, 'end' => $end, 'fetched' => time(), ), false ); $this->settings->record_ga4_poll( '' ); } /** * The stored GA4 snapshot, or an empty shell. * * @return array */ public static function analytics_snapshot() { $stored = get_option( self::GA4_OPTION, array() ); return is_array( $stored ) ? $stored : array(); } /** * Merge a fresh daily window into the stored history: fresh days replace * stored ones (Search Console revises recent days), older history stays — * the series keeps growing where Google's own window ends. Sorted by * date, capped from the OLD end. * * @param array $stored Stored rows [{date, clicks, impressions}]. * @param array $fresh This poll's window rows, same shape. * @param int $keep Days kept at most. * @return array */ public static function merge_daily( array $stored, array $fresh, $keep ) { $by_date = array(); foreach ( $stored as $row ) { if ( ! empty( $row['date'] ) ) { $by_date[ (string) $row['date'] ] = $row; } } foreach ( $fresh as $row ) { if ( ! empty( $row['date'] ) ) { $by_date[ (string) $row['date'] ] = array( 'date' => (string) $row['date'], 'clicks' => (int) ( isset( $row['clicks'] ) ? $row['clicks'] : 0 ), 'impressions' => (int) ( isset( $row['impressions'] ) ? $row['impressions'] : 0 ), ); } } ksort( $by_date ); return array_slice( array_values( $by_date ), -1 * max( 1, (int) $keep ) ); } /** * One budgeted chunk of the index sweep ({@see Index::sweep()} for why it * is chunked). Called by the daily poll, by the REST "Check now", and by * the continuation event; when a chunk leaves work pending, the next * continuation is scheduled here — so no single web request ever carries * the whole watchlist. * * @param float $budget Seconds this chunk may spend inspecting. * @return array|null The stored payload, or null when not connected. */ /** * One live inspection, on the owner's click. Same auth path as the sweep, * one URL instead of a queue, and no cron: this is never scheduled, because * it exists to answer a question someone is asking right now. * * @param string $url The URL to inspect. * @return array{status:string,row:?array,error:string} */ public function inspect_one( $url ) { $sa_json = $this->settings->sa_json(); $property = (string) $this->settings->get( 'property', '' ); if ( '' === $sa_json || '' === $property ) { return array( 'status' => 'error', 'row' => null, 'error' => __( 'Google Search Console is not connected.', 'agentimus' ) ); } $auth = Auth::token( $sa_json ); if ( isset( $auth['error'] ) ) { return array( 'status' => 'error', 'row' => null, 'error' => (string) $auth['error'] ); } return Index::inspect_now( $this->client, $auth['token'], $property, $url ); } public function run_index_sweep( $budget = 6.0 ) { $sa_json = $this->settings->sa_json(); $property = (string) $this->settings->get( 'property', '' ); if ( '' === $sa_json || '' === $property ) { return null; } $auth = Auth::token( $sa_json ); if ( isset( $auth['error'] ) ) { // A dead token fails every inspection identically — record once, keep // the last good answers. $stored = Index::stored(); $stored['error'] = (string) $auth['error']; update_option( Index::OPTION, $stored, false ); return $stored; } // The registered-sitemaps snapshot rides the sweep, refreshed once per // RUN, not per chunk — one cheap GET answering the question no on-site // check can: does Google still fetch what the owner once registered? if ( ! Index::run_in_flight() ) { Index::store_sitemaps( $this->client->sitemaps( $auth['token'], $property ) ); } $out = Index::sweep( $this->client, $auth['token'], $property, Index::run_targets(), $budget ); // A safety net, not the fast path: the panel's own polling loop usually // finishes the run in seconds; this picks it up if the owner closed the // tab mid-sweep (or the daily cron started it). Re-armed from the LAST // chunk rather than the first, so while that loop is chunking every few // seconds the event keeps sliding out of reach — a net and a live loop // inspecting the same URLs would spend Google's budget twice. // // It stands down the moment the run it was netting is over, because an // event that outlives its run lands on an empty queue — and an empty // queue is exactly how {@see Index::sweep()} is told to START one. Left // armed, the net would begin a whole-site sweep nobody asked for, arm // itself again, and never stop. // // A silent blip (a slow answer shy of Index::BLIP_LIMIT) keeps the net // armed — the run should heal on its own. After a LOUD pause it stands // down too: a one-minute retry against a dead network or a dead token is // a loop, not a net. That queue waits for the owner's next panel visit // or the daily sweep. // // And never under a run the owner stopped: a net that carries on after // Cancel makes the button a lie. (A chunk already in flight when the // pause lands can still arm one — its own read of the flag predates // the press. That event fires once, finds the run paused, and stands // down without spending anything.) wp_clear_scheduled_hook( self::CRON_INDEX ); if ( ! empty( $out['queue'] ) && '' === (string) $out['error'] && ! Index::is_paused() ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::CRON_INDEX ); } return $out; } /** * The continuation event's handler. A continuation with nothing to continue * is not a new run: {@see Index::sweep()} reads an empty queue as "start * today's", so a stray event — one armed mid-run that the panel's own loop * then outran to the finish — would start a whole-site sweep on its own. * The net only ever resumes; starting is the owner's click or the daily cron. * * @return void */ public function index_chunk() { // {@see Index::sweep()} refuses a paused run on its own; standing down // here as well keeps a stray event from costing a token fetch and a // sitemaps call to learn what one option read already says. if ( ! Index::run_in_flight() || Index::is_paused() ) { return; } $this->run_index_sweep( 15.0 ); } /** * The owner pressed Cancel. The chunk already with Google is paid for * either way and is left to land; what this stops is everything that would * carry the run on afterwards — the continuation event comes down, and * {@see Index::sweep()} will refuse every path into it until a deliberate * start lifts the pause. * * @return bool Whether a run was actually stopped. */ public function pause_index_sweep() { $stopped = Index::pause(); if ( $stopped ) { wp_clear_scheduled_hook( self::CRON_INDEX ); } return $stopped; } /** * Match this site against the properties a key can read. Preference order: * the exact URL-prefix property for this origin, then the sc-domain * property covering this host — the closest claim wins. Unverified * permission levels don't count as a match. * * @param array $sites Rows from {@see Client::sites()}. * @return string The property string, or '' when nothing matches. */ public static function match_property( array $sites ) { $home = trailingslashit( (string) home_url( '/' ) ); $host = strtolower( (string) wp_parse_url( $home, PHP_URL_HOST ) ); $bare = 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : $host; $domain_match = ''; foreach ( $sites as $site ) { $property = (string) ( $site['property'] ?? '' ); $permission = (string) ( $site['permission'] ?? '' ); if ( '' === $property || 'siteUnverifiedUser' === $permission ) { continue; } if ( 0 === strpos( $property, 'sc-domain:' ) ) { $prop_host = strtolower( substr( $property, 10 ) ); if ( $prop_host === $bare || $prop_host === $host ) { $domain_match = $property; } continue; } $prop_url = trailingslashit( $property ); $prop_host = strtolower( (string) wp_parse_url( $prop_url, PHP_URL_HOST ) ); if ( $prop_url === $home || $prop_host === $host ) { return $property; // The exact origin claim — the best possible match. } } return $domain_match; } }