Plugin Directory

Changeset 3783147


Ignore:
Timestamp:
11/04/2026 06:02:59 AM (4 days ago)
Author:
funnelwon
Message:

Release 3.1.4: search uses less WordPress server resources (search boxes go straight to the results page, fewer WordPress database queries); crawlers, monitors and scripts get a short search results page; Smart 404 skips scanner-word addresses and more scanner and spam patterns are ignored.

Location:
searchus/trunk
Files:
1 added
11 edited

Legend:

Unmodified
Added
Removed
  • searchus/trunk/core/api-client.php

    r3769819 r3783147  
    18611861}
    18621862
     1863if ( ! defined( 'FW_SI_OPTION_API_BACKOFF' ) ) {
     1864  /** Option: visitor searches skip the API until this time (after HTTP 429 Retry-After, or the API not answering). */
     1865  define( 'FW_SI_OPTION_API_BACKOFF', 'fw_si_api_backoff' );
     1866}
     1867
     1868/**
     1869 * Active API pause for visitor searches: ['until' => unix time, 'code' => 429 or 0], or [] when none.
     1870 * Stored autoloaded, so checking it on every search costs no database query.
     1871 *
     1872 * @return array{until?:int,code?:int}
     1873 */
     1874function fw_si_api_backoff(): array {
     1875  $v = get_option( FW_SI_OPTION_API_BACKOFF, null );
     1876  if ( $v === null ) {
     1877    add_option( FW_SI_OPTION_API_BACKOFF, [], '', true );
     1878    return [];
     1879  }
     1880  return ( is_array( $v ) && (int) ( $v['until'] ?? 0 ) > time() ) ? $v : [];
     1881}
     1882
     1883/**
     1884 * Pause visitor searches for $seconds (1–120). A longer pause already running is kept.
     1885 *
     1886 * @param int $seconds Pause length.
     1887 * @param int $code    429 (service asked to slow down) or 0 (service did not answer).
     1888 */
     1889function fw_si_api_backoff_start( int $seconds, int $code ): void {
     1890  $until = time() + max( 1, min( 120, $seconds ) );
     1891  $cur   = fw_si_api_backoff();
     1892  if ( (int) ( $cur['until'] ?? 0 ) >= $until ) {
     1893    return;
     1894  }
     1895  update_option( FW_SI_OPTION_API_BACKOFF, [ 'until' => $until, 'code' => $code ], true );
     1896}
     1897
     1898/**
     1899 * Start a pause when the service answered 429 (uses its Retry-After) or could not be reached in time.
     1900 * Without it, every search during the burst or outage holds a PHP worker for the full HTTP timeout.
     1901 *
     1902 * @param array|WP_Error $response wp_remote_post() result.
     1903 */
     1904function fw_si_api_backoff_note_response( $response ): void {
     1905  if ( is_wp_error( $response ) ) {
     1906    /**
     1907     * Seconds visitor searches skip the API after it could not be reached in time.
     1908     *
     1909     * @param int $seconds Default 10.
     1910     */
     1911    fw_si_api_backoff_start( (int) apply_filters( 'fw_si_api_unreachable_pause_seconds', 10 ), 0 );
     1912    return;
     1913  }
     1914  if ( (int) wp_remote_retrieve_response_code( $response ) === 429 ) {
     1915    $retry = (int) wp_remote_retrieve_header( $response, 'retry-after' );
     1916    fw_si_api_backoff_start( $retry > 0 ? $retry : 5, 429 );
     1917  }
     1918}
     1919
    18631920/**
    18641921 * Tell the service to count a visitor search that was answered from the local cache.
    … …  
    18671924 * @param string $query        Search text the visitor submitted.
    18681925 * @param string $live_session Optional live search session id; the service counts one search per session.
     1926 * @param bool   $is_preview   True for a Predictive Search preview.
     1927 * @param string $source       'smart404' for a Smart 404 recovery search; empty otherwise.
     1928 * @param string $crawler      Search engine or AI crawler short name; empty for visitors.
     1929 * @param string $crawler_ip   The crawler request's address, verified by the service.
    18691930 * @return bool True when the service counted the search.
    18701931 */
    1871 function fw_si_report_cached_search_usage( string $query, string $live_session = '', bool $is_preview = false ): bool {
     1932function fw_si_report_cached_search_usage( string $query, string $live_session = '', bool $is_preview = false, string $source = '', string $crawler = '', string $crawler_ip = '' ): bool {
    18721933  $site_id = function_exists( 'fw_si_get_site_id' ) ? fw_si_get_site_id() : '';
    18731934  if ( $site_id === '' || $query === '' ) {
    … …  
    18751936  }
    18761937  if ( function_exists( 'fw_si_is_junk_query' ) && fw_si_is_junk_query( $query ) ) {
     1938    return false;
     1939  }
     1940  // The cached results are still shown; the search is just not counted while the service asked for a pause.
     1941  if ( fw_si_api_backoff() ) {
    18771942    return false;
    18781943  }
    … …  
    18821947  if ( function_exists( 'fw_si_tier' ) ) {
    18831948    $body['tier'] = fw_si_tier();
     1949  }
     1950  if ( $source === 'smart404' ) {
     1951    $body['source'] = 'smart404';
     1952  }
     1953  if ( $crawler !== '' && ! $is_preview ) {
     1954    $body['crawler']    = $crawler;
     1955    $body['crawler_ip'] = $crawler_ip;
    18841956  }
    18851957  if ( $live_session !== '' ) {
    … …  
    19011973    $url,
    19021974    [
    1903       'timeout' => 8,
     1975      /**
     1976       * Seconds a visitor's page waits for the service to count a cached search.
     1977       *
     1978       * @param int $timeout Default 3.
     1979       */
     1980      'timeout' => (int) apply_filters( 'fw_si_query_count_timeout', 3 ),
    19041981      'headers' => $headers,
    19051982      'body'    => $encoded,
    19061983    ]
    19071984  );
     1985  fw_si_api_backoff_note_response( $response );
    19081986  if ( is_wp_error( $response ) ) {
    19091987    return false;
    … …  
    19332011 *                        ['live_session' => string] = live search session id (one counted search per session).
    19342012 *                        ['live_preview' => int] = live search preview: completes a partial last word, top N results.
     2013 *                        ['source' => 'smart404'] = Smart 404 recovery search (the service drops scanner path probes).
    19352014 * @return array|null Payload (all, content, media, ...) or debug payload (debug_results); null on failure.
    19362015 */
    … …  
    19722051    : '';
    19732052  $live_preview   = isset( $options['live_preview'] ) ? max( 0, (int) $options['live_preview'] ) : 0;
     2053  $search_source  = ( isset( $options['source'] ) && $options['source'] === 'smart404' ) ? 'smart404' : '';
     2054  $crawler        = ( $live_preview === 0 && isset( $options['crawler'] ) && is_string( $options['crawler'] ) ) ? sanitize_key( $options['crawler'] ) : '';
     2055  $crawler_ip     = ( $crawler !== '' && isset( $options['crawler_ip'] ) && is_string( $options['crawler_ip'] ) ) ? $options['crawler_ip'] : '';
    19742056  // Transient cache applies to all tiers so repeat queries avoid POST /v1/query. Return true from
    19752057  // fw_si_query_api_skip_transient_cache to bypass (e.g. if service-supplied attribution HTML must stay in lockstep with usage before TTL).
    … …  
    19872069          fw_si_query_cache_extend_ttl( $cache_key );
    19882070        }
    1989         if ( empty( $options['skip_log'] ) && function_exists( 'fw_si_report_cached_search_usage' ) && fw_si_report_cached_search_usage( $query, $live_session, $live_preview > 0 ) ) {
     2071        if ( empty( $options['skip_log'] ) && function_exists( 'fw_si_report_cached_search_usage' ) && fw_si_report_cached_search_usage( $query, $live_session, $live_preview > 0, $search_source, $crawler, $crawler_ip ) ) {
    19902072          $cached['_fw_usage_counted'] = true;
    19912073        }
    … …  
    20102092    }
    20112093  }
     2094  $backoff = $is_debug ? [] : fw_si_api_backoff();
     2095  if ( $backoff ) {
     2096    $wait = max( 1, (int) $backoff['until'] - time() );
     2097    return [
     2098      '_fw_error'        => true,
     2099      '_fw_error_code'   => (int) ( $backoff['code'] ?? 0 ),
     2100      /* translators: %d: seconds until searches are sent to the service again */
     2101      '_fw_error_detail' => sprintf( __( 'Searches paused for %d seconds after the service was busy or unreachable.', 'searchus' ), $wait ),
     2102    ];
     2103  }
    20122104  $base = fw_si_get_api_base_url();
    20132105  $url  = $is_debug ? ( $base . '/v1/debug/query' ) : ( $base . '/v1/query' );
    … …  
    20292121    $body['preview_limit'] = $live_preview;
    20302122  }
     2123  if ( $search_source !== '' ) {
     2124    $body['source'] = $search_source;
     2125  }
     2126  if ( $crawler !== '' ) {
     2127    $body['crawler']    = $crawler;
     2128    $body['crawler_ip'] = $crawler_ip;
     2129  }
    20312130  if ( ! $is_debug && function_exists( 'fw_si_query_cache_take_refresh' ) && fw_si_query_cache_take_refresh( $cache_key ) ) {
    20322131    $body['cache_refresh'] = true;
    … …  
    20472146
    20482147  $qt_http_start = $qt_api_debug ? microtime( true ) : 0;
     2148  // A visitor's page holds a PHP worker while it waits; debug and pre-cache runs are not on a visitor's page.
     2149  $visitor_wait = $is_debug || ! empty( $options['skip_log'] ) ? 15 : (int) apply_filters( 'fw_si_query_api_timeout', 6, $live_preview > 0 );
    20492150  $response = wp_remote_post(
    20502151    $url,
    20512152    [
    2052       'timeout' => 15,
     2153      'timeout' => $visitor_wait,
    20532154      'headers' => $headers,
    20542155      'body'    => $query_encoded,
    20552156    ]
    20562157  );
     2158  if ( ! $is_debug ) {
     2159    fw_si_api_backoff_note_response( $response );
     2160  }
    20572161  if ( $qt_api_debug ) { fw_si_plugin_diagnostic_log( '[FW_SI_QT] api_source=http query=' . substr( $query, 0, 40 ) . ' http_ms=' . round( ( microtime( true ) - $qt_http_start ) * 1000 ) ); }
    20582162  if ( is_wp_error( $response ) ) {
    … …  
    21692273      fw_si_query_cache_register_doc_ids( $cache_key, $decoded );
    21702274    }
    2171     if ( function_exists( 'fw_si_query_cache_trim_to_top' ) ) {
    2172       fw_si_query_cache_trim_to_top();
     2275    if ( function_exists( 'fw_si_query_cache_schedule_trim' ) ) {
     2276      fw_si_query_cache_schedule_trim();
    21732277    }
    21742278    if ( ! $is_debug && empty( $options['skip_log'] ) && function_exists( 'fw_si_log_query_for_precache' ) ) {
  • searchus/trunk/core/query-cache.php

    r3749625 r3783147  
    357357        }
    358358}
     359
     360/**
     361 * Trim the cache in a cron run once it holds more than FW_SI_QUERY_CACHE_MAX_ENTRIES searches.
     362 * Trimming asks the service for its top tokens, which a visitor's search should not wait for.
     363 */
     364function fw_si_query_cache_schedule_trim() {
     365        $map = get_option( FW_SI_OPTION_QUERY_CACHE_MAP, [] );
     366        if ( ! is_array( $map ) || count( $map ) <= (int) FW_SI_QUERY_CACHE_MAX_ENTRIES ) {
     367                return;
     368        }
     369        if ( ! wp_next_scheduled( 'fw_si_query_cache_trim' ) ) {
     370                wp_schedule_single_event( time(), 'fw_si_query_cache_trim' );
     371        }
     372}
     373add_action( 'fw_si_query_cache_trim', 'fw_si_query_cache_trim_to_top' );
    359374
    360375/**
  • searchus/trunk/core/query-guard.php

    r3749625 r3783147  
    122122        }
    123123
     124        // Scanner signatures that reach search as words once "/" and "-" become spaces (Smart 404),
     125        // and torrent listings pasted as searches. Same rules as the SearchUs API.
     126        if ( preg_match( '/\bproc\s+self\b|(?:^|\s)@fs(?:\s|$)|\btfvars\b|\btfstate\b|\bapi_server\b|\bbafk[a-z2-7]{20,}|\bz9x8c7v6b5\b/i', $lower ) ) {
     127                return true;
     128        }
     129        if ( preg_match( '/\b[0-9a-f]{32,40}\b|&tr=|magnet:|urn:btih/i', $lower ) ) {
     130                return true;
     131        }
     132        if ( preg_match( '/[\'"]\s*>|>\s*[\'"]/', $q ) ) {
     133                return true;
     134        }
     135        // Only symbols ("**", "'>\""). A lone "*" is the browse-all query.
     136        if ( $q !== '*' && ! preg_match( '/[\p{L}\p{N}]/u', $q ) ) {
     137                return true;
     138        }
     139
    124140        return false;
     141}
     142
     143/**
     144 * True when a Smart 404 search (words from a missing URL) is only scanner path words and numbers,
     145 * e.g. /admin/login/ or /api/v1/config/. Not used for typed searches: a visitor may search "login" on purpose.
     146 *
     147 * @param string $query Query derived from the missing URL.
     148 * @return bool
     149 */
     150function fw_si_is_smart404_probe_query( $query ): bool {
     151        static $probe = null;
     152        if ( $probe === null ) {
     153                $probe = array_flip(
     154                        preg_split(
     155                                '/\s+/',
     156                                'admin administrator login logout signin signup register auth oauth sso user users account accounts profile '
     157                                . 'api apis rest graphql v1 v2 v3 v4 config configs configuration settings env environment environ secret secrets '
     158                                . 'token tokens key keys credential credentials private public backup backups bak old new test tests tmp temp dev '
     159                                . 'staging stage prod production debug console server status health healthz metrics actuator ui uploads upload '
     160                                . 'wp wordpress includes content ajax json xmlrpc cgi bin vendor plugin plugins theme themes assets core js css '
     161                                . 'jquery min map maint maintenance install setup shell cmd db database sql dump git svn docs doc cron log logs '
     162                                . 'phpmyadmin adminer dashboard panel cpanel manager portal internal system root home swagger openapi schema'
     163                        )
     164                );
     165        }
     166        $words = preg_split( '/[^a-z0-9]+/', strtolower( (string) $query ), -1, PREG_SPLIT_NO_EMPTY );
     167        if ( ! is_array( $words ) || ! $words ) {
     168                return false;
     169        }
     170        $has_probe_word = false;
     171        foreach ( $words as $w ) {
     172                if ( isset( $probe[ $w ] ) ) {
     173                        $has_probe_word = true;
     174                } elseif ( ! ctype_digit( $w ) ) {
     175                        return false;
     176                }
     177        }
     178        return $has_probe_word;
    125179}
    126180
    … …  
    141195                return $is_bot;
    142196        }
     197        $is_bot = fw_si_is_bot_user_agent_request();
     198        return $is_bot;
     199}
     200
     201/**
     202 * Same test as fw_si_is_bot_request() for use before `init`: a login cookie stands in for is_user_logged_in(),
     203 * because asking for the current user that early can run before plugins add their login handlers.
     204 *
     205 * @return bool
     206 */
     207function fw_si_is_bot_request_early(): bool {
     208        if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {
     209                return false;
     210        }
     211        if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
     212                return false;
     213        }
     214        return fw_si_is_bot_user_agent_request();
     215}
     216
     217/**
     218 * Search engine or AI crawler named by the user agent, as the short name the SearchUs service verifies
     219 * ('google', 'bing', 'openai', …), or ''. Anyone can send these user agents: the service checks the
     220 * request's address, and shows results only to the real crawler. Crawler searches are never counted.
     221 *
     222 * @return string
     223 */
     224function fw_si_search_crawler_name(): string {
     225        static $name = null;
     226        if ( $name !== null ) {
     227                return $name;
     228        }
     229        $ua    = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';
     230        $found = '';
     231        if ( $ua !== '' ) {
     232                $crawlers = array(
     233                        'google'     => 'googlebot|google-inspectiontool',
     234                        'bing'       => 'bingbot|adidxbot',
     235                        'duckduckgo' => 'duckduckbot|duckassistbot',
     236                        'yandex'     => 'yandexbot',
     237                        'baidu'      => 'baiduspider',
     238                        'apple'      => 'applebot',
     239                        'yahoo'      => 'slurp',
     240                        'naver'      => 'yeti\/',
     241                        'seznam'     => 'seznambot',
     242                        'qwant'      => 'qwantbot|qwantify',
     243                        'openai'     => 'gptbot|oai-searchbot|chatgpt-user',
     244                        'anthropic'  => 'claudebot|claude-user|claude-searchbot',
     245                        'perplexity' => 'perplexitybot|perplexity-user',
     246                );
     247                foreach ( $crawlers as $short => $pattern ) {
     248                        if ( preg_match( '/' . $pattern . '/', $ua ) ) {
     249                                $found = $short;
     250                                break;
     251                        }
     252                }
     253        }
     254        /**
     255         * Search engine or AI crawler for the current request ('' for none). Smart 404 pages, and results pages
     256         * on the Free plan, show such crawlers real results and the attribution; other bots get the theme's 404
     257         * or the short results page.
     258         *
     259         * @param string $found Short crawler name, or ''.
     260         * @param string $ua    Lowercased user agent.
     261         */
     262        $name = (string) apply_filters( 'fw_si_search_crawler', $found, $ua );
     263        return $name;
     264}
     265
     266/**
     267 * True on the Free plan, where results pages carry the SearchUs attribution and search engines are
     268 * welcome to crawl them. On paid plans results pages are kept away from crawlers.
     269 *
     270 * @return bool
     271 */
     272function fw_si_crawlers_read_results_pages(): bool {
     273        $free = function_exists( 'fw_si_tier' ) && fw_si_tier() === 'free';
     274        /**
     275         * Whether search engine and AI crawlers get full results pages (and robots.txt does not block them).
     276         *
     277         * @param bool $allow Default: true on the Free plan, false on paid plans.
     278         */
     279        return (bool) apply_filters( 'fw_si_crawlers_read_results_pages', $free );
     280}
     281
     282/**
     283 * User agent half of bot detection (no login check).
     284 *
     285 * @return bool
     286 */
     287function fw_si_is_bot_user_agent_request(): bool {
    143288        $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';
    144289        // Every browser sends a user agent; an empty one is a script.
    … …  
    155300         * @param string $ua     Lowercased user agent.
    156301         */
    157         $is_bot = (bool) apply_filters( 'fw_si_is_bot_request', $is_bot, $ua );
    158         return $is_bot;
     302        return (bool) apply_filters( 'fw_si_is_bot_request', $is_bot, $ua );
    159303}
    160304
  • searchus/trunk/core/tier.php

    r3713689 r3783147  
    77
    88/**
    9  * Plan/tier detection. Single source of truth: plan name (Free, Growth, Pro, Business, Enterprise, Custom).
     9 * Plan/tier detection. Single source of truth: plan name (Free, Starter, Growth, Scale, Pro, Enterprise, Custom).
    1010 * API and plugin use the same concept; binary "free vs paid" is derived from plan.
    1111 *
    … …  
    1414
    1515/** Canonical plan names (API and plugin). */
    16 define( 'FW_SI_PLANS', 'Free,Growth,Pro,Business,Enterprise,Custom' );
     16define( 'FW_SI_PLANS', 'Free,Starter,Growth,Scale,Pro,Enterprise,Custom' );
    1717
    1818/**
    … …  
    9090
    9191/**
    92  * Current plan name: 'Free' | 'Growth' | 'Pro' | 'Business' | 'Enterprise' | 'Custom'.
     92 * Current plan name: 'Free' | 'Starter' | 'Growth' | 'Scale' | 'Pro' | 'Enterprise' | 'Custom'.
    9393 * From activation response (stored in fw_si_license_plan); default 'Free' if no license or not set.
    9494 */
    9595function fw_si_plan(): string {
    96   $plans = array( 'Free', 'Growth', 'Pro', 'Business', 'Enterprise', 'Custom' );
     96  $plans = array( 'Free', 'Starter', 'Growth', 'Scale', 'Pro', 'Enterprise', 'Custom' );
    9797
    9898  // From activation response (stored in fw_si_license_plan)
  • searchus/trunk/fw-search-indexer.php

    r3769819 r3783147  
    55 * Plugin Name: SearchUs – Site Search for WordPress and WooCommerce
    66 * Description: Intelligent indexing, intent scoring, and SERP control for WordPress.
    7  * Version: 3.1.3
     7 * Version: 3.1.4
    88 * Author: FunnelWon
    99 * Author URI: https://searchus.io
    … …  
    129129========================================================= */
    130130
    131 define( 'FW_SI_VERSION', '3.1.3' );
     131define( 'FW_SI_VERSION', '3.1.4' );
    132132define( 'FW_SI_PATCH', 13 );
    133133define( 'FW_SI_PLUGIN_DIR', __DIR__ );
    … …  
    340340  $fw_si_lock_bypass[] = 'fw_si_search_fallback';
    341341  $fw_si_lock_bypass[] = 'fw_si_attribution_check';
     342  $fw_si_lock_bypass[] = 'fw_si_api_backoff';
    342343  if ( in_array( $option, $fw_si_lock_bypass, true ) ) {
    343344    return $value;
    … …  
    889890}
    890891require_once FW_SI_PLUGIN_DIR . '/serp/search-integration.php';
     892require_once FW_SI_PLUGIN_DIR . '/serp/search-request-guard.php';
    891893require_once FW_SI_PLUGIN_DIR . '/serp/smart-404.php';
    892894require_once FW_SI_PLUGIN_DIR . '/admin/result-control-admin.php';
  • searchus/trunk/languages/searchus.pot

    r3769819 r3783147  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: SearchUs – Site Search for WordPress and WooCommerce 3.1.3\n"
     5"Project-Id-Version: SearchUs – Site Search for WordPress and WooCommerce 3.1.4\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/searchus\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-10-28T03:06:56+00:00\n"
     12"POT-Creation-Date: 2026-11-04T05:37:56+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    942942#. translators: %d: Minimum excerpt length in characters (UTF-8).
    943943#: admin/result-control-admin.php:470
    944 #: fw-search-indexer.php:517
     944#: fw-search-indexer.php:518
    945945#, php-format
    946946msgid "Required for this type: at least %d characters (UTF-8). For products this is the short description under the price."
    … …  
    11851185#: admin/search-embeds-admin.php:265
    11861186#: admin/settings-page.php:4776
    1187 #: fw-search-indexer.php:429
     1187#: fw-search-indexer.php:430
    11881188msgid "Delete"
    11891189msgstr ""
    … …  
    12511251#: admin/settings-page.php:956
    12521252#: admin/settings-page.php:982
    1253 #: fw-search-indexer.php:478
     1253#: fw-search-indexer.php:479
    12541254msgid "Another user"
    12551255msgstr ""
    12561256
    12571257#: admin/settings-page.php:242
    1258 #: fw-search-indexer.php:479
     1258#: fw-search-indexer.php:480
    12591259msgid "Editing in progress."
    12601260msgstr ""
    … …  
    13851385#: admin/settings-page.php:1256
    13861386#: admin/settings-page.php:3968
    1387 #: serp/serp-content-block.php:2284
    1388 #: serp/serp-content-block.php:2709
     1387#: serp/serp-content-block.php:2287
     1388#: serp/serp-content-block.php:2712
    13891389msgid "Read more"
    13901390msgstr ""
    … …  
    14871487#: admin/settings-page.php:1480
    14881488#: analytics/analytics-ui.php:763
    1489 #: serp/serp-content-block.php:1852
    1490 #: serp/serp-content-block.php:2739
     1489#: serp/serp-content-block.php:1855
     1490#: serp/serp-content-block.php:2742
    14911491msgid "Previous"
    14921492msgstr ""
    … …  
    14941494#: admin/settings-page.php:1494
    14951495#: analytics/analytics-ui.php:763
    1496 #: serp/serp-content-block.php:1867
    1497 #: serp/serp-content-block.php:2754
     1496#: serp/serp-content-block.php:1870
     1497#: serp/serp-content-block.php:2757
    14981498msgid "Next"
    14991499msgstr ""
    … …  
    15651565#: admin/settings-register.php:1020
    15661566#: core/hmac-auth.php:213
    1567 #: fw-search-indexer.php:734
    1568 #: fw-search-indexer.php:796
     1567#: fw-search-indexer.php:735
     1568#: fw-search-indexer.php:797
    15691569#: serp/search-page.php:695
    15701570msgid "Unauthorized"
    … …  
    17931793
    17941794#: admin/settings-page.php:3198
    1795 #: fw-search-indexer.php:438
     1795#: fw-search-indexer.php:439
    17961796msgid "Disable Crawling"
    17971797msgstr ""
    17981798
    17991799#: admin/settings-page.php:3198
    1800 #: fw-search-indexer.php:439
     1800#: fw-search-indexer.php:440
    18011801msgid "Enable Crawling"
    18021802msgstr ""
    … …  
    20122012
    20132013#: admin/settings-page.php:3760
    2014 #: fw-search-indexer.php:436
     2014#: fw-search-indexer.php:437
    20152015msgid "Reindex in progress."
    20162016msgstr ""
    … …  
    21322132#: admin/settings-page.php:4046
    21332133#: admin/setup-wizard.php:78
    2134 #: fw-search-indexer.php:484
     2134#: fw-search-indexer.php:485
    21352135msgid "Unlock Settings"
    21362136msgstr ""
    21372137
    21382138#: admin/settings-page.php:4046
    2139 #: fw-search-indexer.php:485
     2139#: fw-search-indexer.php:486
    21402140msgid "Lock Settings"
    21412141msgstr ""
    … …  
    21672167#: admin/settings-page.php:5104
    21682168#: admin/settings-page.php:5223
    2169 #: fw-search-indexer.php:428
     2169#: fw-search-indexer.php:429
    21702170msgid "Expand"
    21712171msgstr ""
    … …  
    28662866
    28672867#: admin/settings-page.php:5350
    2868 #: fw-search-indexer.php:435
     2868#: fw-search-indexer.php:436
    28692869msgid "Minimize"
    28702870msgstr ""
    … …  
    38133813#. translators: 1: Current page number, 2: Total number of pages.
    38143814#: analytics/analytics-ui.php:761
    3815 #: serp/serp-content-block.php:1859
    3816 #: serp/serp-content-block.php:2746
     3815#: serp/serp-content-block.php:1862
     3816#: serp/serp-content-block.php:2749
    38173817#, php-format
    38183818msgid "Page %1$d of %2$d"
    … …  
    38283828
    38293829#: analytics/analytics-ui.php:763
    3830 #: serp/serp-content-block.php:1905
     3830#: serp/serp-content-block.php:1908
    38313831msgid "Results Per Page:"
    38323832msgstr ""
    … …  
    41604160msgctxt "HTML link in API notice"
    41614161msgid "Manage your plan or limits: %s"
     4162msgstr ""
     4163
     4164#. translators: %d: seconds until searches are sent to the service again
     4165#: core/api-client.php:2101
     4166#, php-format
     4167msgid "Searches paused for %d seconds after the service was busy or unreachable."
    41624168msgstr ""
    41634169
    … …  
    44964502
    44974503#: crawler/crawl-confirm.php:512
    4498 #: fw-search-indexer.php:446
     4504#: fw-search-indexer.php:447
    44994505msgid "Auto-Crawl Disabled"
    45004506msgstr ""
    … …  
    45024508#. translators: %d: Number of pages sent to the service and waiting for it to confirm they are indexed.
    45034509#: crawler/crawl-confirm.php:516
    4504 #: fw-search-indexer.php:449
     4510#: fw-search-indexer.php:450
    45054511#, php-format
    45064512msgid "Waiting for confirmation (%d)"
    … …  
    45084514
    45094515#: crawler/crawl-confirm.php:519
    4510 #: fw-search-indexer.php:447
     4516#: fw-search-indexer.php:448
    45114517msgid "Active — Processing queue"
    45124518msgstr ""
    45134519
    45144520#: crawler/crawl-confirm.php:521
    4515 #: fw-search-indexer.php:450
     4521#: fw-search-indexer.php:451
    45164522msgid "Idle — No queued items"
    45174523msgstr ""
    … …  
    45454551msgstr ""
    45464552
    4547 #: fw-search-indexer.php:413
     4553#: fw-search-indexer.php:414
    45484554msgid "Result"
    45494555msgstr ""
    45504556
    4551 #: fw-search-indexer.php:414
     4557#: fw-search-indexer.php:415
    45524558msgid "Injected"
    45534559msgstr ""
    45544560
    45554561#. translators: %s: Previous title or label for a reordered search result.
    4556 #: fw-search-indexer.php:416
     4562#: fw-search-indexer.php:417
    45574563#, php-format
    45584564msgid "(Previously %s)"
    45594565msgstr ""
    45604566
    4561 #: fw-search-indexer.php:425
     4567#: fw-search-indexer.php:426
    45624568msgid "Error"
    45634569msgstr ""
    45644570
    4565 #: fw-search-indexer.php:426
     4571#: fw-search-indexer.php:427
    45664572msgid "Reindex failed on server."
    45674573msgstr ""
    45684574
    4569 #: fw-search-indexer.php:427
     4575#: fw-search-indexer.php:428
    45704576msgid "Collapse"
    45714577msgstr ""
    45724578
    4573 #: fw-search-indexer.php:430
     4579#: fw-search-indexer.php:431
    45744580msgid "Deleting..."
    45754581msgstr ""
    45764582
    45774583#. translators: %s = comma-separated list of query phrases where the URL is a duplicate
    4578 #: fw-search-indexer.php:432
     4584#: fw-search-indexer.php:433
    45794585#, php-format
    45804586msgid "This URL already appears in organic search results for: %s. Duplicate injections are automatically skipped on the SERP, so this injection will not display. Remove the URL from the injection or from the index first."
    45814587msgstr ""
    45824588
    4583 #: fw-search-indexer.php:433
     4589#: fw-search-indexer.php:434
    45844590msgid "Save is taking longer than expected. The page did not finish loading — check whether your changes were saved, then try again."
    45854591msgstr ""
    45864592
    4587 #: fw-search-indexer.php:434
     4593#: fw-search-indexer.php:435
    45884594msgid "Close"
    45894595msgstr ""
    45904596
    4591 #: fw-search-indexer.php:437
     4597#: fw-search-indexer.php:438
    45924598msgid "Search index is rebuilding. Controls are temporarily locked."
    45934599msgstr ""
    45944600
    4595 #: fw-search-indexer.php:440
     4601#: fw-search-indexer.php:441
    45964602msgid "Active — Reindexing"
    45974603msgstr ""
    45984604
    4599 #: fw-search-indexer.php:441
     4605#: fw-search-indexer.php:442
    46004606msgid "Active"
    46014607msgstr ""
    46024608
    4603 #: fw-search-indexer.php:442
     4609#: fw-search-indexer.php:443
    46044610msgid "Paused"
    46054611msgstr ""
    46064612
    4607 #: fw-search-indexer.php:443
     4613#: fw-search-indexer.php:444
    46084614msgid "Indexing is paused."
    46094615msgstr ""
    46104616
    4611 #: fw-search-indexer.php:444
     4617#: fw-search-indexer.php:445
    46124618msgid "1 or more items are queued for crawling. Once crawling is enabled, these will be processed automatically."
    46134619msgstr ""
    46144620
    4615 #: fw-search-indexer.php:445
     4621#: fw-search-indexer.php:446
    46164622msgid "Unknown"
    46174623msgstr ""
    46184624
    4619 #: fw-search-indexer.php:451
     4625#: fw-search-indexer.php:452
    46204626msgid "Reindexing…"
    46214627msgstr ""
    46224628
    4623 #: fw-search-indexer.php:452
     4629#: fw-search-indexer.php:453
    46244630msgid "Settings are Locked - Click Unlock Settings to make edits"
    46254631msgstr ""
    46264632
    4627 #: fw-search-indexer.php:453
     4633#: fw-search-indexer.php:454
    46284634msgid "Settings are locked. Unlock settings to clear cache."
    46294635msgstr ""
    46304636
    4631 #: fw-search-indexer.php:454
     4637#: fw-search-indexer.php:455
    46324638msgid "Failed to clear cache."
    46334639msgstr ""
    46344640
    4635 #: fw-search-indexer.php:455
     4641#: fw-search-indexer.php:456
    46364642msgid "Settings are locked. Unlock settings to run pre-cache."
    46374643msgstr ""
    46384644
    4639 #: fw-search-indexer.php:456
     4645#: fw-search-indexer.php:457
    46404646msgid "Pre-cache failed."
    46414647msgstr ""
    46424648
    4643 #: fw-search-indexer.php:457
     4649#: fw-search-indexer.php:458
    46444650msgid "Reset endpoint is not available. Please refresh and try again."
    46454651msgstr ""
    46464652
    4647 #: fw-search-indexer.php:458
     4653#: fw-search-indexer.php:459
    46484654msgid "Settings are locked. Unlock settings to reset."
    46494655msgstr ""
    46504656
    4651 #: fw-search-indexer.php:459
     4657#: fw-search-indexer.php:460
    46524658msgid "Reset all settings to defaults? This cannot be undone."
    46534659msgstr ""
    46544660
    4655 #: fw-search-indexer.php:460
     4661#: fw-search-indexer.php:461
    46564662msgid "Final confirmation: this will overwrite your current settings. Continue?"
    46574663msgstr ""
    46584664
    4659 #: fw-search-indexer.php:461
     4665#: fw-search-indexer.php:462
    46604666msgid "Failed to reset settings."
    46614667msgstr ""
    46624668
    4663 #: fw-search-indexer.php:462
     4669#: fw-search-indexer.php:463
    46644670msgid "Replace all existing synonyms?"
    46654671msgstr ""
    46664672
    4667 #: fw-search-indexer.php:463
     4673#: fw-search-indexer.php:464
    46684674msgid "Replace all existing variant option aliases?"
    46694675msgstr ""
    46704676
    4671 #: fw-search-indexer.php:464
     4677#: fw-search-indexer.php:465
    46724678msgid "Replace all existing crawl exclusion rules?"
    46734679msgstr ""
    46744680
    4675 #: fw-search-indexer.php:465
     4681#: fw-search-indexer.php:466
    46764682msgid "Suggesting…"
    46774683msgstr ""
    46784684
    4679 #: fw-search-indexer.php:466
     4685#: fw-search-indexer.php:467
    46804686msgid "Suggest failed. Try again."
    46814687msgstr ""
    46824688
    4683 #: fw-search-indexer.php:467
     4689#: fw-search-indexer.php:468
    46844690msgid "Unlock settings to use Suggest."
    46854691msgstr ""
    46864692
    4687 #: fw-search-indexer.php:468
     4693#: fw-search-indexer.php:469
    46884694msgid ""
    46894695"Rebuild the search index now?\n"
    … …  
    46924698msgstr ""
    46934699
    4694 #: fw-search-indexer.php:469
     4700#: fw-search-indexer.php:470
    46954701msgid "Settings are locked. Unlock settings to change crawling."
    46964702msgstr ""
    46974703
    4698 #: fw-search-indexer.php:470
     4704#: fw-search-indexer.php:471
    46994705msgid "Enabling..."
    47004706msgstr ""
    47014707
    4702 #: fw-search-indexer.php:471
     4708#: fw-search-indexer.php:472
    47034709msgid "Disabling..."
    47044710msgstr ""
    47054711
    4706 #: fw-search-indexer.php:472
     4712#: fw-search-indexer.php:473
    47074713msgid "Settings are locked. Unlock settings to reindex."
    47084714msgstr ""
    47094715
    4710 #: fw-search-indexer.php:473
     4716#: fw-search-indexer.php:474
    47114717msgid "Analyzing..."
    47124718msgstr ""
    47134719
    4714 #: fw-search-indexer.php:474
     4720#: fw-search-indexer.php:475
    47154721msgid "Run Full Reindex"
    47164722msgstr ""
    47174723
    4718 #: fw-search-indexer.php:475
     4724#: fw-search-indexer.php:476
    47194725msgid "Reindex failed. Check your license and try again."
    47204726msgstr ""
    47214727
    4722 #: fw-search-indexer.php:476
     4728#: fw-search-indexer.php:477
    47234729msgid "OK"
    47244730msgstr ""
    47254731
    4726 #: fw-search-indexer.php:477
     4732#: fw-search-indexer.php:478
    47274733msgid "Reindex request failed. This may be a timeout on large sites. Refresh the page to check if the reindex completed in the background."
    47284734msgstr ""
    47294735
    47304736#. translators: %s = editor display name
    4731 #: fw-search-indexer.php:481
     4737#: fw-search-indexer.php:482
    47324738#, php-format
    47334739msgid "%s is currently editing FunnelWon Search Engine settings. You can view configuration, but editing is temporarily disabled."
    … …  
    47354741
    47364742#. translators: %s: Remaining time until editing may be available (human-readable, e.g. countdown).
    4737 #: fw-search-indexer.php:483
     4743#: fw-search-indexer.php:484
    47384744#, php-format
    47394745msgid "Editing access may become available in %s."
    47404746msgstr ""
    47414747
    4742 #: fw-search-indexer.php:486
     4748#: fw-search-indexer.php:487
    47434749msgid "- Settings are read-only to prevent accidental changes."
    47444750msgstr ""
    47454751
    4746 #: fw-search-indexer.php:487
     4752#: fw-search-indexer.php:488
    47474753msgid "- Changes are enabled. Remember to lock settings when finished."
    47484754msgstr ""
    47494755
    4750 #: fw-search-indexer.php:488
     4756#: fw-search-indexer.php:489
    47514757msgid "Unlocking..."
    47524758msgstr ""
    47534759
    4754 #: fw-search-indexer.php:489
     4760#: fw-search-indexer.php:490
    47554761msgid "Locking..."
    47564762msgstr ""
    47574763
    4758 #: fw-search-indexer.php:490
     4764#: fw-search-indexer.php:491
    47594765msgid "Dismiss this notice."
    47604766msgstr ""
    47614767
    4762 #: fw-search-indexer.php:491
     4768#: fw-search-indexer.php:492
    47634769msgid ""
    47644770"Disable FunnelWon Search?\n"
    … …  
    47704776
    47714777#. translators: %s = user display name
    4772 #: fw-search-indexer.php:493
     4778#: fw-search-indexer.php:494
    47734779#, php-format
    47744780msgid "%s has taken over. Refreshing…"
    47754781msgstr ""
    47764782
    4777 #: fw-search-indexer.php:494
     4783#: fw-search-indexer.php:495
    47784784msgid "Settings are currently locked."
    47794785msgstr ""
    47804786
    47814787#. translators: %s = countdown timer string like "2:30"
    4782 #: fw-search-indexer.php:496
     4788#: fw-search-indexer.php:497
    47834789#, php-format
    47844790msgid " Try again in %s."
    … …  
    47864792
    47874793#. translators: %s = editor display name
    4788 #: fw-search-indexer.php:498
     4794#: fw-search-indexer.php:499
    47894795#, php-format
    47904796msgid "%s is currently editing these settings."
    47914797msgstr ""
    47924798
    4793 #: fw-search-indexer.php:499
     4799#: fw-search-indexer.php:500
    47944800msgid ""
    47954801"\n"
    … …  
    47984804msgstr ""
    47994805
    4800 #: fw-search-indexer.php:500
     4806#: fw-search-indexer.php:501
    48014807msgid "Could not verify edit session. Please refresh the page and try again."
    48024808msgstr ""
    48034809
    4804 #: fw-search-indexer.php:501
     4810#: fw-search-indexer.php:502
    48054811msgid "Military Time:"
    48064812msgstr ""
    48074813
    4808 #: fw-search-indexer.php:502
    4809 #: serp/serp-content-block.php:1849
    4810 #: serp/serp-content-block.php:2736
     4814#: fw-search-indexer.php:503
     4815#: serp/serp-content-block.php:1852
     4816#: serp/serp-content-block.php:2739
    48114817msgid "Pagination"
    48124818msgstr ""
    48134819
    4814 #: fw-search-indexer.php:503
     4820#: fw-search-indexer.php:504
    48154821msgid "Enter a query above and click \"Show results\" to load the preview list."
    48164822msgstr ""
    48174823
    4818 #: fw-search-indexer.php:504
     4824#: fw-search-indexer.php:505
    48194825msgid "No results for this query."
    48204826msgstr ""
    48214827
    4822 #: fw-search-indexer.php:505
     4828#: fw-search-indexer.php:506
    48234829msgid "(Promoted)"
    48244830msgstr ""
    48254831
    4826 #: fw-search-indexer.php:506
     4832#: fw-search-indexer.php:507
    48274833msgid "Could not load results."
    48284834msgstr ""
    48294835
    4830 #: fw-search-indexer.php:507
     4836#: fw-search-indexer.php:508
    48314837msgid "Removing reorder rule…"
    48324838msgstr ""
    48334839
    4834 #: fw-search-indexer.php:508
     4840#: fw-search-indexer.php:509
    48354841msgid "Could not remove reorder rule."
    48364842msgstr ""
    48374843
    4838 #: fw-search-indexer.php:509
     4844#: fw-search-indexer.php:510
    48394845msgid "Saving reorder rule…"
    48404846msgstr ""
    48414847
    4842 #: fw-search-indexer.php:510
     4848#: fw-search-indexer.php:511
    48434849msgid "Reorder rule save failed."
    48444850msgstr ""
    48454851
    4846 #: fw-search-indexer.php:511
     4852#: fw-search-indexer.php:512
    48474853msgid "Removing…"
    48484854msgstr ""
    48494855
    4850 #: fw-search-indexer.php:512
     4856#: fw-search-indexer.php:513
    48514857msgid "Remove failed"
    48524858msgstr ""
    48534859
    4854 #: fw-search-indexer.php:513
     4860#: fw-search-indexer.php:514
    48554861msgid "Paste a single embed URL (https://www.youtube.com/embed/… or player.vimeo.com/…) or the full iframe block from the host's share dialog (we read src). Watch links are converted when possible. Must end up as https on an allowed host. After the first save, this cannot be changed."
    48564862msgstr ""
    48574863
    4858 #: fw-search-indexer.php:514
     4864#: fw-search-indexer.php:515
    48594865msgid "Not used for video embed injections. The SERP shows an \"Embed\" line; the modal uses an iframe player."
    48604866msgstr ""
    48614867
    4862 #: fw-search-indexer.php:515
     4868#: fw-search-indexer.php:516
    48634869msgid "Not used for this media type. The SERP shows size and format from the file URL when available (same as organic media results)."
    48644870msgstr ""
    48654871
    4866 #: fw-search-indexer.php:518
     4872#: fw-search-indexer.php:519
    48674873msgid "Please select a content type before saving an injection."
    48684874msgstr ""
    48694875
    48704876#. translators: %1$d = min chars, %2$d = entered chars
    4871 #: fw-search-indexer.php:520
     4877#: fw-search-indexer.php:521
    48724878#, php-format
    48734879msgid "Excerpt must be at least %1$d characters (%2$d entered). Use Clear form if you only want to save other settings."
    … …  
    48754881
    48764882#. translators: 1: result type (e.g. Pages), 2: new position, 3: number of rows. Read out after reordering the All tab.
    4877 #: fw-search-indexer.php:552
     4883#: fw-search-indexer.php:553
    48784884#, php-format
    48794885msgid "%1$s: position %2$d of %3$d"
    … …  
    51225128
    51235129#. translators: %d: number of image results
    5124 #: serp/search-results.php:353
     5130#: serp/search-results.php:379
    51255131#, php-format
    51265132msgid "See %d image"
    … …  
    51305136
    51315137#. translators: %d: number of video results
    5132 #: serp/search-results.php:357
     5138#: serp/search-results.php:383
    51335139#, php-format
    51345140msgid "See %d video"
    … …  
    51385144
    51395145#. translators: %d: number of document results
    5140 #: serp/search-results.php:361
     5146#: serp/search-results.php:387
    51415147#, php-format
    51425148msgid "See %d document"
    … …  
    51465152
    51475153#. translators: %d: number of audio results
    5148 #: serp/search-results.php:365
     5154#: serp/search-results.php:391
    51495155#, php-format
    51505156msgid "See %d audio file"
    … …  
    51545160
    51555161#. translators: %d: number of product results
    5156 #: serp/search-results.php:369
     5162#: serp/search-results.php:395
    51575163#, php-format
    51585164msgid "See %d product"
    … …  
    51615167msgstr[1] ""
    51625168
    5163 #: serp/search-results.php:375
     5169#: serp/search-results.php:401
    51645170msgid "No pages found."
    51655171msgstr ""
    … …  
    52095215
    52105216#. translators: %s: suggested search term (link to corrected search)
    5211 #: serp/serp-content-block.php:1740
     5217#: serp/serp-content-block.php:1743
    52125218#, php-format
    52135219msgid "Did you mean %s?"
    … …  
    52155221
    52165222#. translators: %s: suggested search term (link to corrected search)
    5217 #: serp/serp-content-block.php:1748
     5223#: serp/serp-content-block.php:1751
    52185224#, php-format
    52195225msgid "Search for %s"
    52205226msgstr ""
    52215227
    5222 #: serp/serp-content-block.php:1767
     5228#: serp/serp-content-block.php:1770
    52235229msgid "Search result types"
    52245230msgstr ""
    52255231
    5226 #: serp/serp-content-block.php:1795
     5232#: serp/serp-content-block.php:1798
    52275233msgid "Page not found; here are similar results."
    52285234msgstr ""
    52295235
    52305236#. translators: %s: search query used to find similar results
    5231 #: serp/serp-content-block.php:1799
     5237#: serp/serp-content-block.php:1802
    52325238#, php-format
    52335239msgid "Page not found; here are similar results for %s"
    … …  
    52355241
    52365242#. translators: %s: search query
    5237 #: serp/serp-content-block.php:1806
     5243#: serp/serp-content-block.php:1809
    52385244#, php-format
    52395245msgid "Search Results for: %s"
    … …  
    52415247
    52425248#. translators: 1: matched query term, 2: canonical synonym
    5243 #: serp/serp-content-block.php:1814
     5249#: serp/serp-content-block.php:1817
    52445250#, php-format
    52455251msgid " (%1$s is a synonym of %2$s)"
    … …  
    52475253
    52485254#. translators: %d: number of results
    5249 #: serp/serp-content-block.php:1842
    5250 #: serp/serp-content-block.php:2730
     5255#: serp/serp-content-block.php:1845
     5256#: serp/serp-content-block.php:2733
    52515257#, php-format
    52525258msgid "%d Results"
    52535259msgstr ""
    52545260
    5255 #: serp/serp-content-block.php:2766
     5261#: serp/serp-content-block.php:2769
    52565262msgid "Continue to search results"
    52575263msgstr ""
    52585264
    5259 #: serp/serp-content-block.php:2773
     5265#: serp/serp-content-block.php:2776
    52605266msgid "Authentication failed"
    52615267msgstr ""
    52625268
    5263 #: serp/serp-content-block.php:2775
     5269#: serp/serp-content-block.php:2778
    52645270msgid "Rate limited"
    52655271msgstr ""
    52665272
    5267 #: serp/serp-content-block.php:2777
     5273#: serp/serp-content-block.php:2780
    52685274msgid "Could not reach search API"
    52695275msgstr ""
    52705276
    5271 #: serp/serp-content-block.php:2779
     5277#: serp/serp-content-block.php:2782
    52725278msgid "Search API error"
    52735279msgstr ""
    52745280
    5275 #: serp/serp-content-block.php:2782
     5281#: serp/serp-content-block.php:2785
    52765282msgid "Search is temporarily unavailable. Please try again later."
    52775283msgstr ""
    52785284
    5279 #: serp/serp-content-block.php:2785
     5285#: serp/serp-content-block.php:2788
    52805286msgid "Try a more specific search. Common words like “and” or “the” are skipped."
    52815287msgstr ""
    52825288
    5283 #: serp/serp-content-block.php:2790
     5289#: serp/serp-content-block.php:2793
    52845290msgid "Page not found. We couldn't find similar pages. Try searching above."
    52855291msgstr ""
    52865292
    52875293#. translators: %s: the user's search query
    5288 #: serp/serp-content-block.php:2794
     5294#: serp/serp-content-block.php:2797
    52895295#, php-format
    52905296msgid "No products matched “%s”."
    52915297msgstr ""
    52925298
    5293 #: serp/serp-content-block.php:2797
     5299#: serp/serp-content-block.php:2800
    52945300msgid "You’re searching products only. Try product names, styles, sizes, colors, or a budget — for example “red shoes size 7” or “red shoes under $100”."
    52955301msgstr ""
    … …  
    52975303#. translators: %s: URL to sitewide search without product scope
    52985304#. translators: %s: URL to sitewide search without scope
    5299 #: serp/serp-content-block.php:2807
    5300 #: serp/serp-content-block.php:2829
     5305#: serp/serp-content-block.php:2810
     5306#: serp/serp-content-block.php:2832
    53015307#, php-format
    53025308msgid "<a href=\"%s\">Search everything on this site</a>"
    53035309msgstr ""
    53045310
    5305 #: serp/serp-content-block.php:2813
     5311#: serp/serp-content-block.php:2816
    53065312msgid "this scope"
    53075313msgstr ""
    53085314
    53095315#. translators: 1: search query, 2: scope phrase e.g. Products
    5310 #: serp/serp-content-block.php:2816
     5316#: serp/serp-content-block.php:2819
    53115317#, php-format
    53125318msgid "No results for “%1$s” within %2$s."
    53135319msgstr ""
    53145320
    5315 #: serp/serp-content-block.php:2833
     5321#: serp/serp-content-block.php:2836
    53165322msgid "No results found."
    53175323msgstr ""
    53185324
    5319 #: serp/serp-content-block.php:2863
     5325#: serp/serp-content-block.php:2866
    53205326msgid "Media Preview"
    53215327msgstr ""
    53225328
    5323 #: serp/serp-content-block.php:2865
     5329#: serp/serp-content-block.php:2868
    53245330msgid "Expand to full screen"
    53255331msgstr ""
    53265332
    5327 #: serp/serp-content-block.php:2868
     5333#: serp/serp-content-block.php:2871
    53285334msgid "Close preview"
    53295335msgstr ""
    53305336
    5331 #: serp/serp-content-block.php:2875
     5337#: serp/serp-content-block.php:2878
    53325338msgid "Go to page"
    53335339msgstr ""
    53345340
    5335 #: serp/serp-content-block.php:2876
     5341#: serp/serp-content-block.php:2879
    53365342msgid "Open in new tab"
    53375343msgstr ""
    … …  
    53805386msgid "Sorry, you are not allowed to do that."
    53815387msgstr ""
     5388
     5389#: serp/smart-404.php:611
     5390msgid "Page not found"
     5391msgstr ""
  • searchus/trunk/readme.txt

    r3769819 r3783147  
    44Requires at least: 5.9
    55Tested up to: 7.1
    6 Stable tag: 3.1.3
     6Stable tag: 3.1.4
    77Requires PHP: 7.4
    88License: GPLv2 or later
    … …  
    239239
    240240== Changelog ==
     241
     242= 3.1.4 =
     243* Improved: Search uses less of your server. Search boxes send visitors straight to the results page instead of through a redirect, and the results page loads its results with fewer WordPress database queries.
     244* Improved: Crawlers, monitors and scripts that request a search results page get a short page right away instead of a full results page. Their searches were already not counted.
     245* Improved: Smart 404 no longer searches for missing addresses made only of scanner words (such as /admin/login or /api/config), and more scanner and spam patterns in searches are ignored, so they don't count toward your plan.
    241246
    242247= 3.1.3 =
    … …  
    393398== Upgrade Notice ==
    394399
     400= 3.1.4 =
     401Search uses less of the WordPress server resources and improved blocking of bot crawlers. No reindex required.
     402
    395403= 3.1.3 =
    396404Theme compatibility improvements and a WordPress search fallback.
  • searchus/trunk/serp/page-template-404-recovery.php

    r3601545 r3783147  
    7878        $recovery_query_text = '';
    7979        $hide_recovery_detail = false;
    80         if ( function_exists( 'fw_si_get_search_results' ) ) {
    81                 $payload = fw_si_get_search_results(
    82                         $fw_si_recovery_query,
    83                         [
    84                                 'apply_result_control' => true,
    85                                 'context'              => 'smart404',
    86                         ]
    87                 );
     80        // Same search the status code was decided from (smart-404.php, template_include).
     81        if ( function_exists( 'fw_si_smart404_recovery' ) ) {
     82                $fw_si_recovery       = fw_si_smart404_recovery();
     83                $fw_si_recovery_query = $fw_si_recovery['query'];
     84                $payload              = $fw_si_recovery['payload'];
     85                $recovery_h1          = $fw_si_recovery['h1'];
    8886        }
    8987        // Analytics: do not call fw_si_analytics_record_search here — fw_si_render_serp_content_block()
    9088        // records smart404 searches once when it renders (avoids double count with preloaded payload).
    91         if ( is_array( $payload ) && function_exists( 'fw_si_build_smart404_h1' ) ) {
    92                 $recovery_h1 = fw_si_build_smart404_h1( $fw_si_recovery_query, $payload );
    93         }
    9489        if ( $recovery_h1 !== '' ) {
    9590                $recovery_query_text = $recovery_h1;
  • searchus/trunk/serp/search-results.php

    r3769819 r3783147  
    206206  }
    207207
    208   // Crawlers, monitors and scripts: no API call, so nothing is billed or recorded.
    209   $fw_si_bot = function_exists( 'fw_si_is_bot_request' ) && fw_si_is_bot_request();
     208  $fw_si_is_smart404 = ( $options['context'] ?? '' ) === 'smart404';
     209  // Crawlers, monitors and scripts: no API call, so nothing is billed or recorded. Search engine and AI
     210  // crawlers on a Smart 404 page, or on a results page on the Free plan, are the exception: the service
     211  // never counts their searches and returns results when the request's address is the crawler's.
     212  $fw_si_bot     = function_exists( 'fw_si_is_bot_request' ) && fw_si_is_bot_request();
     213  $fw_si_crawler = ( $fw_si_bot && empty( $options['live_preview'] ) && function_exists( 'fw_si_search_crawler_name' )
     214    && ( $fw_si_is_smart404 || ( function_exists( 'fw_si_crawlers_read_results_pages' ) && fw_si_crawlers_read_results_pages() ) ) )
     215    ? fw_si_search_crawler_name() : '';
     216  if ( $fw_si_crawler !== '' ) {
     217    $fw_si_bot = false;
     218  }
    210219  // Only stop words: the API would drop every word and still bill the search, so skip it.
    211220  // Live previews are exempt: their last word is a prefix ("and" may complete to "android").
    212221  $fw_si_stop_only = empty( $options['live_preview'] ) && function_exists( 'fw_si_is_stop_words_only_query' ) && fw_si_is_stop_words_only_query( $api_query );
    213   if ( $fw_si_bot || $fw_si_stop_only ) {
     222  // A missing URL made only of scanner path words (/admin/login, /api/config) is a probe, not a visitor.
     223  $fw_si_probe = $fw_si_is_smart404 && function_exists( 'fw_si_is_smart404_probe_query' ) && fw_si_is_smart404_probe_query( $api_query );
     224  if ( $fw_si_bot || $fw_si_stop_only || $fw_si_probe ) {
    214225    if ( $fw_si_bot ) {
    215226      // A page cache must not keep this empty page and serve it to visitors.
    … …  
    254265          $api_options[ $live_key ] = $options[ $live_key ];
    255266        }
     267      }
     268      if ( $fw_si_is_smart404 ) {
     269        $api_options['source'] = 'smart404';
     270      }
     271      if ( $fw_si_crawler !== '' ) {
     272        $api_options['crawler'] = $fw_si_crawler;
     273        // REMOTE_ADDR only: forwarded-for headers are set by whoever sends the request.
     274        $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '';
     275        /**
     276         * Address sent to the service to verify a crawler's search (default REMOTE_ADDR).
     277         * Behind a proxy that does not restore the visitor's address, return the trusted client address here.
     278         *
     279         * @param string $remote_addr Request address.
     280         */
     281        $api_options['crawler_ip'] = (string) apply_filters( 'fw_si_search_crawler_ip', $remote_addr );
    256282      }
    257283      // Results page reached from live search (Enter / See all): same session, so it is not counted twice.
    … …  
    548574
    549575/**
     576 * Load the posts for a page of results, with their meta, terms and featured images, in a few queries
     577 * instead of several per result.
     578 *
     579 * @param array $ids Result IDs (int or "media:attachment:123"); others are skipped.
     580 */
     581function fw_si_prime_result_post_caches( array $ids ): void {
     582  if ( ! function_exists( '_prime_post_caches' ) || ! function_exists( 'fw_si_resolve_post_id' ) ) {
     583    return;
     584  }
     585  $post_ids = [];
     586  foreach ( $ids as $rid ) {
     587    $pid = ( is_int( $rid ) || is_string( $rid ) ) ? fw_si_resolve_post_id( $rid ) : null;
     588    if ( $pid !== null && $pid > 0 ) {
     589      $post_ids[ $pid ] = $pid;
     590    }
     591  }
     592  if ( ! $post_ids ) {
     593    return;
     594  }
     595  _prime_post_caches( array_values( $post_ids ), true, true );
     596  $thumbs = [];
     597  foreach ( $post_ids as $pid ) {
     598    $thumb = (int) get_post_meta( $pid, '_thumbnail_id', true );
     599    if ( $thumb > 0 ) {
     600      $thumbs[ $thumb ] = $thumb;
     601    }
     602  }
     603  if ( $thumbs ) {
     604    _prime_post_caches( array_values( $thumbs ), false, true );
     605  }
     606}
     607
     608/**
    550609 * Convert payload IDs to WP_Post array for the_posts filter.
    551610 *
    … …  
    554613 */
    555614function fw_si_payload_ids_to_posts( array $ids ): array {
     615  fw_si_prime_result_post_caches( $ids );
    556616  $posts = [];
    557617  foreach ( $ids as $pid ) {
  • searchus/trunk/serp/serp-content-block.php

    r3769819 r3783147  
    16461646        }
    16471647        $qt_build_start = $qt_serp_debug ? microtime( true ) : 0;
     1648        if ( function_exists( 'fw_si_prime_result_post_caches' ) ) {
     1649          fw_si_prime_result_post_caches( $is_404_recovery ? array_slice( $slice, 0, 12 ) : $slice );
     1650        }
    16481651        foreach ( $slice as $rid ) {
    16491652          if ( $is_404_recovery && count( $serp_display ) >= 6 ) {
  • searchus/trunk/serp/smart-404.php

    r3769819 r3783147  
    7474
    7575/**
     76 * True when the missing URL ends in an image, style, script, font or media file extension.
     77 *
     78 * @return bool
     79 */
     80function fw_si_smart404_path_is_static_file(): bool {
     81        $uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : '';
     82        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
     83        return (bool) preg_match( '/\.(?:jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?|css|js|mjs|map|json|xml|txt|woff2?|ttf|otf|eot|mp4|webm|mov|mp3|wav|ogg|zip|gz)$/i', $path );
     84}
     85
     86/**
     87 * True when the missing URL is being opened as a page by a visitor's browser or a search engine or AI crawler.
     88 * Those crawlers see the recovery page with similar results and the attribution. Other crawlers and scripts,
     89 * and missing images or scripts, get the theme's 404.
     90 *
     91 * @return bool
     92 */
     93function fw_si_smart404_request_wants_page(): bool {
     94        if ( function_exists( 'fw_si_is_bot_request' ) && fw_si_is_bot_request() ) {
     95                // Crawlers often send Accept: */*, so file type comes from the URL.
     96                return function_exists( 'fw_si_search_crawler_name' ) && fw_si_search_crawler_name() !== '' && ! fw_si_smart404_path_is_static_file();
     97        }
     98        $dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_SEC_FETCH_DEST'] ) ) ) : '';
     99        if ( $dest !== '' ) {
     100                return in_array( $dest, array( 'document', 'iframe', 'frame' ), true );
     101        }
     102        // Browsers without Sec-Fetch-Dest still list text/html when opening a page.
     103        $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_ACCEPT'] ) ) ) : '';
     104        return strpos( $accept, 'text/html' ) !== false;
     105}
     106
     107/**
    76108 * Derive a search query from the 404 URL.
    77109 *
    … …  
    301333        }
    302334        $serving_recovery = ! empty( $GLOBALS['fw_si_serving_404_recovery'] );
    303         $is_404_recovery = $serving_recovery || ( is_404() && (bool) get_option( 'fw_si_smart_404_enabled', true ) && fw_si_derive_query_from_404_url() !== '' );
     335        $is_404_recovery = $serving_recovery || ( is_404() && (bool) get_option( 'fw_si_smart_404_enabled', true ) && fw_si_smart404_request_wants_page() && fw_si_derive_query_from_404_url() !== '' );
    304336        if ( ! $is_404_recovery ) {
    305337                return;
    … …  
    380412                return;
    381413        }
     414        if ( ! fw_si_smart404_request_wants_page() ) {
     415                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
     416                        fw_si_smart404_debug_bail( 'not_a_visitor_page_request' );
     417                }
     418                return;
     419        }
    382420        $query = fw_si_derive_query_from_404_url();
    383421        if ( $query === '' ) {
    … …  
    422460
    423461/**
     462 * Search, payload and heading for this request's Smart 404 page. The search runs once, before the page
     463 * header is sent, so the HTTP status can say whether similar results were found.
     464 *
     465 * @return array{query:string,payload:array,h1:string,found:bool}
     466 */
     467function fw_si_smart404_recovery(): array {
     468        static $recovery = null;
     469        if ( $recovery !== null ) {
     470                return $recovery;
     471        }
     472        $query   = fw_si_derive_query_from_404_url();
     473        $payload = ( $query !== '' && function_exists( 'fw_si_get_search_results' ) )
     474                ? fw_si_get_search_results(
     475                        $query,
     476                        [
     477                                'apply_result_control' => true,
     478                                'context'              => 'smart404',
     479                        ]
     480                )
     481                : [];
     482        $payload  = is_array( $payload ) ? $payload : [];
     483        $recovery = [
     484                'query'   => $query,
     485                'payload' => $payload,
     486                'h1'      => $payload ? fw_si_build_smart404_h1( $query, $payload ) : '',
     487                // Similar means a word from the missing URL is in the shown results' titles, not just any fuzzy match.
     488                'found'   => fw_si_smart404_url_words_in_titles( $query, $payload ) && fw_si_smart404_payload_has_results( $payload ),
     489        ];
     490        return $recovery;
     491}
     492
     493/**
     494 * True when a word from the missing URL (plural or not) appears in a title of the first 12 results.
     495 *
     496 * @param string $query   Query derived from the URL.
     497 * @param array  $payload Search payload.
     498 * @return bool
     499 */
     500function fw_si_smart404_url_words_in_titles( string $query, array $payload ): bool {
     501        // Singular forms a word may stand for: "hoodies" → hoodie, "boxes" → box, "berries" → berry.
     502        $forms = static function ( string $word ): array {
     503                $out = [ $word ];
     504                $len = strlen( $word );
     505                if ( $len > 3 && substr( $word, -1 ) === 's' ) {
     506                        $out[] = substr( $word, 0, -1 );
     507                }
     508                if ( $len > 4 && substr( $word, -2 ) === 'es' ) {
     509                        $out[] = substr( $word, 0, -2 );
     510                }
     511                if ( $len > 4 && substr( $word, -3 ) === 'ies' ) {
     512                        $out[] = substr( $word, 0, -3 ) . 'y';
     513                }
     514                return $out;
     515        };
     516        $filler    = array_flip( array( '404', 'www', 'http', 'https', 'html', 'htm', 'php', 'com', 'net', 'org', 'bad', 'url', 'page', 'post', 'search', 'old', 'new', 'the', 'and', 'for', 'with' ) );
     517        $url_words = [];
     518        foreach ( preg_split( '/[^a-z0-9]+/', strtolower( $query ), -1, PREG_SPLIT_NO_EMPTY ) ?: [] as $word ) {
     519                if ( strlen( $word ) >= 2 && ! isset( $filler[ $word ] ) ) {
     520                        foreach ( $forms( $word ) as $form ) {
     521                                $url_words[ $form ] = true;
     522                        }
     523                }
     524        }
     525        if ( ! $url_words || empty( $payload['all'] ) || ! is_array( $payload['all'] ) ) {
     526                return false;
     527        }
     528        $titles = isset( $payload['titles'] ) && is_array( $payload['titles'] ) ? $payload['titles'] : [];
     529        foreach ( array_slice( $payload['all'], 0, 12 ) as $rid ) {
     530                $title = isset( $titles[ $rid ] ) ? (string) $titles[ $rid ] : '';
     531                if ( $title === '' && function_exists( 'fw_si_resolve_post_id' ) && ( is_int( $rid ) || is_string( $rid ) ) ) {
     532                        $post_id = fw_si_resolve_post_id( $rid );
     533                        $title   = $post_id ? (string) get_the_title( $post_id ) : '';
     534                }
     535                foreach ( preg_split( '/[^a-z0-9]+/', strtolower( $title ), -1, PREG_SPLIT_NO_EMPTY ) ?: [] as $word ) {
     536                        foreach ( $forms( $word ) as $form ) {
     537                                if ( isset( $url_words[ $form ] ) ) {
     538                                        return true;
     539                                }
     540                        }
     541                }
     542        }
     543        return false;
     544}
     545
     546/**
     547 * True when the payload has at least one result the Smart 404 page shows.
     548 *
     549 * @param array $payload Search payload.
     550 * @return bool
     551 */
     552function fw_si_smart404_payload_has_results( array $payload ): bool {
     553        if ( ! empty( $payload['_fw_error'] ) || empty( $payload['all'] ) || ! is_array( $payload['all'] ) ) {
     554                return false;
     555        }
     556        foreach ( array_slice( $payload['all'], 0, 50 ) as $rid ) {
     557                if ( function_exists( 'fw_si_result_control_is_synthetic_result_id' ) && fw_si_result_control_is_synthetic_result_id( $rid ) ) {
     558                        return true;
     559                }
     560                $post_id = ( function_exists( 'fw_si_resolve_post_id' ) && ( is_int( $rid ) || is_string( $rid ) ) ) ? fw_si_resolve_post_id( $rid ) : null;
     561                $post    = $post_id ? get_post( $post_id ) : null;
     562                if ( $post instanceof WP_Post && ( ! function_exists( 'fw_si_serp_post_allowed_for_404_recovery' ) || fw_si_serp_post_allowed_for_404_recovery( $post ) ) ) {
     563                        return true;
     564                }
     565        }
     566        return false;
     567}
     568
     569/**
     570 * This request's URL (no query string), the Smart 404 page's own address.
     571 *
     572 * @return string
     573 */
     574function fw_si_smart404_page_url(): string {
     575        $uri  = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : '/';
     576        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
     577        $home = wp_parse_url( home_url( '/' ) );
     578        if ( ! is_array( $home ) || empty( $home['host'] ) ) {
     579                return '';
     580        }
     581        $port = isset( $home['port'] ) ? ':' . (int) $home['port'] : '';
     582        return esc_url_raw( ( $home['scheme'] ?? 'https' ) . '://' . $home['host'] . $port . ( $path !== '' ? $path : '/' ) );
     583}
     584
     585/**
     586 * Head tags for the Smart 404 page. The page is built on the results page with the home page's header, so
     587 * WordPress and SEO plugins would otherwise name the home page as canonical and use the site name as title.
     588 * With results: canonical is the page's own URL, so search engines can index it. Without: no canonical.
     589 *
     590 * @param array $recovery fw_si_smart404_recovery().
     591 */
     592function fw_si_smart404_page_head( array $recovery ): void {
     593        remove_action( 'wp_head', 'rel_canonical' );
     594        remove_action( 'wp_head', 'wp_shortlink_wp_head', 10 );
     595        $canonical = ! empty( $recovery['found'] ) ? fw_si_smart404_page_url() : '';
     596        $canonical_filter = static function () use ( $canonical ) {
     597                return $canonical !== '' ? $canonical : false;
     598        };
     599        add_filter( 'wpseo_canonical', $canonical_filter, 999 );
     600        add_filter( 'rank_math/frontend/canonical', $canonical_filter, 999 );
     601        if ( $canonical !== '' && ! defined( 'WPSEO_VERSION' ) && ! defined( 'RANK_MATH_VERSION' ) ) {
     602                add_action(
     603                        'wp_head',
     604                        static function () use ( $canonical ) {
     605                                echo '<link rel="canonical" href="' . esc_url( $canonical ) . '" />' . "\n";
     606                        },
     607                        10
     608                );
     609        }
     610        $heading = ! empty( $recovery['found'] ) && isset( $recovery['h1'] ) ? trim( (string) $recovery['h1'] ) : '';
     611        $title   = ( $heading !== '' ? $heading : __( 'Page not found', 'searchus' ) ) . ' | ' . get_bloginfo( 'name', 'display' );
     612        $title_filter = static function () use ( $title ) {
     613                return esc_html( $title );
     614        };
     615        add_filter( 'pre_get_document_title', $title_filter, 999 );
     616        add_filter( 'wpseo_title', $title_filter, 999 );
     617        add_filter( 'rank_math/frontend/title', $title_filter, 999 );
     618}
     619
     620/**
    424621 * template_include: Serve recovery template when we replaced 404 with Search page (theme header renders).
    425622 * Runs at 5 so we override before search-page.php (25) when serving Smart 404.
    … …  
    441638        $recovery_template = FW_SI_PLUGIN_DIR . '/serp/page-template-404-recovery.php';
    442639        if ( file_exists( $recovery_template ) ) {
    443                 status_header( 404 );
     640                // A page of similar results is content search engines may index; a page with none is still a 404.
     641                $recovery = fw_si_smart404_recovery();
     642                status_header( $recovery['found'] ? 200 : 404 );
    444643                nocache_headers();
     644                fw_si_smart404_page_head( $recovery );
    445645                return $recovery_template;
    446646        }
Note: See TracChangeset for help on using the changeset viewer.