ensure_key(); if ( '' !== $key ) { Router::add_file( 'indexnow', self::KEY_REGEX, 'text/plain', [ $this, 'render_key' ], [ 'path' => $key . '.txt', 'extra_query' => '&' . Router::KEY_VAR . '=$matches[1]', 'noindex' => true, ] ); } add_action( 'transition_post_status', [ $this, 'transition_post_status' ], 10, 3 ); add_action( 'acid_aeo_indexnow_flush', [ $this, 'flush_queue' ] ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); } /** * Renders the key file, proving ownership of the requested key. * * The rewrite rule captures any key the setting could hold, so the captured * value has to be checked against the stored key before it is echoed back; * `hash_equals()` keeps that check constant time. A key that does not * match answers with nothing, which `Endpoints\Router` turns into a 404. * * @return string|null The key, or null when the requested key does not match. */ public function render_key(): ?string { $stored = (string) Options::get( 'indexnow.key', '' ); $requested = sanitize_key( (string) get_query_var( Router::KEY_VAR ) ); if ( '' === $stored || '' === $requested || ! hash_equals( $stored, $requested ) ) { return null; } return $stored; } /** * Queues a post's permalink whenever it starts or stops being published. * * Fires on every `transition_post_status`, including a plain resave of an * already published post: IndexNow exists to tell search engines a URL's * content changed, and an edit is exactly that. `is_listed_post()` keeps * an autosave, a revision and a post type nobody can read out of the * queue. * * The permalink is read with `get_permalink()` at hook time rather than * captured before the status change. For an unpublish or a trash this can * differ from the URL the page was actually reachable at a moment ago * (WordPress may already have touched the post's slug by the time this * fires); it is accepted here because it keeps the queued URL and the * one a visitor would currently be redirected to in agreement, and a * search engine re-crawling a no-longer-published URL is exactly the * outcome IndexNow is for. * * @param mixed $new_status Status the post moves to. * @param mixed $old_status Status the post had. * @param mixed $post Post object, as `transition_post_status` passes it. * @return void */ public function transition_post_status( $new_status, $old_status, $post = null ): void { if ( ! ( $post instanceof WP_Post ) ) { return; } if ( ! (bool) Options::get( 'indexnow.auto_submit', true ) ) { return; } $post_types = Options::get( 'indexnow.post_types', [] ); if ( ! in_array( $post->post_type, is_array( $post_types ) ? $post_types : [], true ) ) { return; } if ( ! (bool) get_option( 'blog_public' ) ) { return; } if ( ! $this->is_listed_post( (int) $post->ID, $post ) ) { return; } $becomes_published = 'publish' === $new_status; $leaves_published = 'publish' === $old_status && 'publish' !== $new_status; if ( ! $becomes_published && ! $leaves_published ) { return; } $url = (string) get_permalink( $post ); if ( '' !== $url ) { $this->enqueue( $url ); } } /** * Adds one URL to the flush queue. * * A thin wrapper around `enqueue_many()` for the single-URL callers * (`transition_post_status()`, `flush_queue()`'s callers via `submit()`). * * @param string $url Absolute URL. * @return void */ public function enqueue( string $url ): void { $this->enqueue_many( [ $url ] ); } /** * Adds many URLs to the flush queue in one read/merge/write. * * Reads the queue once, merges it with `$urls` through `merge_queue()` * (deduped, capped at `QUEUE_MAX`, oldest entries dropped first), writes * it back once, and schedules the flush cron once — unless one is already * pending, so a burst of edits is sent as one batch. `submit_all()` calls * this once per page of posts instead of once per post, which is the * difference between 10 option writes and 5,000 for a 5,000-post site. * * @param array $urls Absolute URLs. * @return int Number of URLs from `$urls` that were not already queued. */ public function enqueue_many( array $urls ): int { $clean = []; foreach ( $urls as $url ) { $url = trim( (string) $url ); if ( '' !== $url ) { $clean[] = $url; } } if ( [] === $clean ) { return 0; } $queue = $this->queue(); $existing = array_flip( $queue ); $added = 0; foreach ( array_unique( $clean ) as $url ) { if ( ! isset( $existing[ $url ] ) ) { ++$added; } } $this->save_queue( self::merge_queue( $queue, $clean, self::QUEUE_MAX ) ); if ( ! wp_next_scheduled( 'acid_aeo_indexnow_flush' ) ) { wp_schedule_single_event( time() + 60, 'acid_aeo_indexnow_flush' ); } return $added; } /** * The URLs currently waiting for the next flush. * * @return array Queued URLs. */ public function queue(): array { $queue = get_option( self::QUEUE_OPTION, [] ); return is_array( $queue ) ? array_values( $queue ) : []; } /** * Cron handler: hands the queue to `submit()` and empties it. * * A failed submission is logged by `submit()` but not retried — by * design. The URLs are already cleared from the queue by the time * `submit()` runs, so a failure just waits for the next edit (or a * manual `submit_all()`) to queue the URL again. * * @return void */ public function flush_queue(): void { if ( ! $this->is_enabled() ) { return; } $urls = $this->queue(); if ( [] === $urls ) { return; } $this->clear_queue(); $this->submit( $urls ); } /** * Submits a list of URLs to the IndexNow API, in chunks of `CHUNK_SIZE`. * * Every chunk is logged, oldest first push out at `LOG_MAX`; the entry * for the last chunk sent is returned. * * @param array $urls URLs to submit. * @return array The last log entry, or an empty array when there was nothing to send. */ public function submit( array $urls ): array { $urls = array_values( array_unique( array_filter( array_map( 'strval', $urls ) ) ) ); // Switched off means switched off: the REST route and the CLI command // construct the module directly, so the gate has to live here. if ( ! $this->is_enabled() ) { return [ 'time' => time(), 'count' => count( $urls ), 'status' => 'error', 'message' => self::switched_off_message(), ]; } $entry = []; foreach ( self::chunk( $urls, self::CHUNK_SIZE ) as $chunk_urls ) { $entry = $this->submit_chunk( $chunk_urls ); } return $entry; } /** * Queues every published post of the configured post types. * * Reads posts 500 at a time so a large site is not loaded into memory at * once; `no_found_rows` skips the `SQL_CALC_FOUND_ROWS` a normal query * would run, since the loop only needs to know when a page came back * short. Each page's permalinks are handed to `enqueue_many()` in one * call, so a 5,000-post site costs 10 option writes rather than 5,000. * * @return int Number of URLs queued. */ public function submit_all(): int { if ( ! $this->is_enabled() ) { return 0; } $post_types = Options::get( 'indexnow.post_types', [] ); $post_types = is_array( $post_types ) ? $post_types : []; if ( [] === $post_types ) { return 0; } $count = 0; $paged = 1; $per_page = 500; do { $query = new WP_Query( [ 'post_type' => $post_types, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => $per_page, 'paged' => $paged, 'no_found_rows' => true, 'orderby' => 'ID', 'order' => 'ASC', ] ); $urls = []; foreach ( $query->posts as $post_id ) { $url = (string) get_permalink( (int) $post_id ); if ( '' !== $url ) { $urls[] = $url; } } if ( [] !== $urls ) { $count += count( $urls ); // The queue keeps `QUEUE_MAX` entries and drops the oldest past // that, so a page that would overflow it is preceded by a flush: // on a site with more URLs than the cap, nothing is lost. if ( count( $this->queue() ) + count( $urls ) > self::QUEUE_MAX ) { $this->flush_queue(); } $this->enqueue_many( $urls ); } $has_more = count( $query->posts ) === $per_page; ++$paged; } while ( $has_more ); return $count; } /** * The submission history, most recent first. * * @return array> Log entries. */ public function log(): array { $log = get_option( self::LOG_OPTION, [] ); return is_array( $log ) ? array_values( $log ) : []; } /** * The one sentence every caller gets when the module is off. * * @return string Translated message. */ public static function switched_off_message(): string { return __( 'IndexNow is switched off. Turn the module on in Acid AEO → Settings to submit URLs.', 'acid-aeo' ); } /** * Absolute URL of the key file. * * @return string Key file URL, or an empty string when the module has not registered it. */ public function key_url(): string { $key = (string) Options::get( 'indexnow.key', '' ); if ( '' === $key ) { return ''; } if ( '' === (string) get_option( 'permalink_structure', '' ) ) { return home_url( '/?' . Router::QUERY_VAR . '=indexnow&' . Router::KEY_VAR . '=' . rawurlencode( $key ) ); } return Router::url( 'indexnow' ); } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { return [ $this->key_check(), $this->recent_check(), ]; } /** * Whether the file that proves the site owns its domain is published. * * Without a key there is no file to publish and no submission would be * accepted, which is a different problem from a key whose file does not * answer — and a different repair. * * @return Check The finding. */ private function key_check(): Check { $id = 'indexnow_key_reachable'; $label = __( 'IndexNow key file is reachable', 'acid-aeo' ); if ( '' === trim( (string) Options::get( 'indexnow.key', '' ) ) ) { return Check::fail( $id, $this->slug(), $label, __( 'No IndexNow key is stored, so there is no verification file to publish and every submission would be rejected.', 'acid-aeo' ), Check::WEIGHT_CRITICAL, 'regenerate_indexnow_key' ); } return Checks::reachable( $id, $this->slug(), $label, $this->key_url(), [ 'type' => 'text/plain', 'weight' => Check::WEIGHT_CRITICAL, 'fix' => 'flush_rewrite', ] ); } /** * Whether anything actually reached the IndexNow API lately. * * A site with nothing published has nothing to submit, and saying so is * more useful than warning about a queue that was correctly empty. * * @return Check The finding. */ private function recent_check(): Check { $id = 'indexnow_recent'; $label = __( 'Recent IndexNow submissions', 'acid-aeo' ); $weight = Check::WEIGHT_NICE; $log = $this->log(); $since = time() - ( 30 * DAY_IN_SECONDS ); foreach ( $log as $entry ) { $status = is_array( $entry ) ? (int) ( $entry['status'] ?? 0 ) : 0; if ( (int) ( $entry['time'] ?? 0 ) >= $since && in_array( $status, [ 200, 202 ], true ) ) { return Check::pass( $id, $this->slug(), $label, __( 'A submission was accepted in the last thirty days.', 'acid-aeo' ), $weight ); } } $counts = Environment::detect()['post_counts'] ?? []; if ( [] === $log && 0 === ( is_array( $counts ) ? (int) array_sum( $counts ) : 0 ) ) { return Check::pass( $id, $this->slug(), $label, __( 'Nothing is published yet, so there has been nothing to submit.', 'acid-aeo' ), $weight ); } return Check::warn( $id, $this->slug(), $label, __( 'No successful IndexNow submission in the last 30 days', 'acid-aeo' ), $weight ); } /** * Builds the JSON body the IndexNow API expects. * * Pure and static so it can be tested without WordPress: `$urls` is * trusted to be whatever the caller wants submitted, and this only keeps * the ones that actually belong to `$host` and removes duplicates, order * preserved. * * @param string $host Site host, e.g. `example.com`. * @param string $key IndexNow key. * @param string $key_location Absolute URL of the key file. * @param array $urls Candidate URLs. * @return array Keys `host`, `key`, `keyLocation` and `urlList`. */ public static function payload( string $host, string $key, string $key_location, array $urls ): array { return [ 'host' => $host, 'key' => $key, 'keyLocation' => $key_location, 'urlList' => array_values( array_unique( array_filter( $urls, static function ( $url ) use ( $host ): bool { $url = (string) $url; return str_starts_with( $url, 'https://' . $host ) || str_starts_with( $url, 'http://' . $host ); } ) ) ), ]; } /** * Splits a list of URLs into chunks no larger than `$size`. * * @param array $urls URLs to split. * @param int $size Maximum chunk size. * @return array> Chunks. */ public static function chunk( array $urls, int $size = 10000 ): array { return array_chunk( array_values( $urls ), max( 1, $size ) ); } /** * Merges new URLs into an existing queue, deduped and capped. * * Pure and static so `enqueue_many()` stays a thin read/merge/write * wrapper around it. Order is preserved — `$queue` first, then `$urls` in * the order given — a URL already present keeps its original position * rather than moving to the end, and once the merged list is longer than * `$max` the oldest entries (the front of the list) are dropped. * * @param array $queue Existing queue. * @param array $urls URLs to add. * @param int $max Maximum entries to keep. * @return array Merged, deduped, capped queue. */ public static function merge_queue( array $queue, array $urls, int $max = 10000 ): array { $merged = []; foreach ( array_merge( array_values( $queue ), array_values( $urls ) ) as $url ) { $url = trim( (string) $url ); if ( '' === $url ) { continue; } $merged[ $url ] = true; } $merged = array_keys( $merged ); if ( count( $merged ) > $max ) { $merged = array_slice( $merged, -$max ); } return array_values( $merged ); } /** * A home URL's host, lowercase and without scheme or port. * * @param string $home_url Site home URL. * @return string Host, e.g. `example.com`. */ public static function normalize_host( string $home_url ): string { $host = wp_parse_url( $home_url, PHP_URL_HOST ); return strtolower( is_string( $host ) ? $host : '' ); } /** * Reads the stored key, generating and storing one when there is none. * * Mirrors the fallback `Activator::activate()` uses: a hosting * environment without a working CSPRNG gets a WordPress-generated * password instead of a fatal error. * * @return string The key, 32 characters long. */ private function ensure_key(): string { $key = (string) Options::get( 'indexnow.key', '' ); if ( '' !== $key ) { return $key; } try { $key = bin2hex( random_bytes( 16 ) ); } catch ( \Exception ) { $key = strtolower( wp_generate_password( 32, false ) ); } if ( '' !== $key ) { Options::set( 'indexnow.key', $key ); } return $key; } /** * Submits one chunk of URLs and records the outcome. * * @param array $urls URLs to submit, already chunked. * @return array The log entry this submission produced. */ private function submit_chunk( array $urls ): array { $host = self::normalize_host( (string) home_url() ); $key = (string) Options::get( 'indexnow.key', '' ); $key_location = $this->key_url(); $body = self::payload( $host, $key, $key_location, $urls ); /** * Filters the IndexNow API endpoint. * * @param string $endpoint Endpoint URL. */ $endpoint = (string) apply_filters( 'acid_aeo_indexnow_endpoint', 'https://api.indexnow.org/indexnow' ); $response = wp_remote_post( $endpoint, [ 'headers' => [ 'Content-Type' => 'application/json; charset=utf-8' ], 'body' => wp_json_encode( $body ), 'timeout' => 15, ] ); $entry = $this->build_log_entry( $response, count( $urls ) ); $this->prepend_log( $entry ); return $entry; } /** * Turns a `wp_remote_post()` result into a log entry. * * @param mixed $response Result of `wp_remote_post()`. * @param int $count Number of URLs the request carried. * @return array Keys `time`, `count`, `status` and `message`. */ private function build_log_entry( $response, int $count ): array { if ( is_wp_error( $response ) ) { return [ 'time' => time(), 'count' => $count, 'status' => 'error', 'message' => $response->get_error_message(), ]; } return [ 'time' => time(), 'count' => $count, 'status' => (int) wp_remote_retrieve_response_code( $response ), 'message' => '', ]; } /** * Prepends a log entry and trims the log to `LOG_MAX`. * * @param array $entry Log entry. * @return void */ private function prepend_log( array $entry ): void { $log = $this->log(); array_unshift( $log, $entry ); $log = array_slice( $log, 0, self::LOG_MAX ); self::ensure_option( self::LOG_OPTION ); update_option( self::LOG_OPTION, $log ); } /** * Saves the queue. * * @param array $queue Queue to save. * @return void */ private function save_queue( array $queue ): void { self::ensure_option( self::QUEUE_OPTION ); update_option( self::QUEUE_OPTION, array_values( $queue ) ); } /** * Empties the queue. * * @return void */ private function clear_queue(): void { self::ensure_option( self::QUEUE_OPTION ); update_option( self::QUEUE_OPTION, [] ); } /** * Creates an option with autoload off, when it does not exist yet. * * @param string $name Option name. * @return void */ private static function ensure_option( string $name ): void { if ( false === get_option( $name, false ) ) { add_option( $name, [], '', 'no' ); } } }