get( 'paged' ) > 1 ) { return; } if ( !in_array( 'product', (array) $wp_query->get( 'post_type' ), true ) || !self::shopper() ) { return; } self::add( (string) get_search_query( false ), (int) $wp_query->found_posts ); } /** * A search a shopper made on the store's own pages (admin audit A7, 28 Oct 2026: crawlers following a store's landing-page * links filled the list with "… indirimli", "… fiyatları 2026"): no crawler's user agent, and a page of this site as the * referrer, which the search box and the instant search send and a crawler following a link does not. */ private static function shopper() : bool { $agent = ( isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '' ); if ( $agent === '' || preg_match( '/bot\\b|bot\\/|crawl|spider|slurp|bingpreview|facebookexternalhit|headless|lighthouse|python|curl|wget|httpclient|scrapy/i', $agent ) ) { return false; } $referrer = (string) wp_get_raw_referer(); return $referrer !== '' && strtolower( (string) wp_parse_url( $referrer, PHP_URL_HOST ) ) === strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); } public static function add( string $raw, int $results = 0 ) : bool { $term = self::clean( $raw ); // A search that found products is no mash, whatever its letters ("dkny", a brand). if ( $term === '' || $results === 0 && self::mash( $term ) ) { return false; } // One recording path for both storefronts. No persistent money locks or visitor identity. return (bool) self::locked( 'counts', static function () use($term, $results) { $n = max( 0, min( 1000000, $results ) ); // One visitor network (an IPv6 /64) spends at most its own share of the store-wide hour (S18); it is only counted, // under a keyed hash, never written into the report. $network = Store::visitor_network(); if ( $network !== '' && !Store::throttle( ( $n === 0 ? 'search-miss' : 'search-hit' ), $network, ( $n === 0 ? self::CLIENT_HOURLY : self::CLIENT_HOURLY_HITS ), HOUR_IN_SECONDS ) ) { return false; } $hour = (( $n === 0 ? 'storesuit_search_misses_' : 'storesuit_search_hits_' )) . gmdate( 'YmdH' ); $used = (int) get_transient( $hour ); if ( $used >= (( $n === 0 ? self::HOURLY : self::HOURLY_HITS )) ) { return false; } set_transient( $hour, $used + 1, HOUR_IN_SECONDS ); $all = self::prune( self::all() ); $key = mb_strtolower( $term ); $now = time(); $row = $all[$key] ?? array( 'term' => $term, 'count' => 0, 'first' => $now, 'hidden' => 0, 'days' => array(), ); if ( !isset( $row['days'] ) ) { $row['legacy_count'] = (int) $row['count']; $row['legacy_last'] = (int) $row['last']; $row['days'] = array(); } $day = gmdate( 'Y-m-d', $now ); $daily = $row['days'][$day] ?? array( 'count' => 0, 'misses' => 0, 'results' => 0, ); ++$daily['count']; $daily['misses'] += ( $n === 0 ? 1 : 0 ); $daily['results'] += $n; $row['days'][$day] = $daily; ++$row['count']; $row['last'] = $now; if ( $n === 0 ) { $row['last_miss'] = $now; } $all[$key] = $row; update_option( self::OPTION, self::prune( $all ), false ); return true; }, false ); } /** A short product word or phrase; addresses, links and long numbers are refused. */ public static function clean( string $raw ) : string { $term = trim( (string) preg_replace( '/\\s+/u', ' ', wp_strip_all_tags( $raw ) ) ); if ( mb_strlen( $term ) < 2 || mb_strlen( $term ) > 60 ) { return ''; } // Phone numbers too, with or without spaces, in any script's digits (Arabic-Indic, full-width…: Codex SEARCH-01). if ( preg_match( '/@|https?:|www\\./i', $term ) || preg_match_all( '/\\p{Nd}/u', $term ) >= 7 ) { return ''; } return $term; } /** * Keys struck at random ("xqzv", "asdfgh", admin audit A7): a word of four letters or more with no vowel, or five * consonants in a row. Sizes ("xxxl", "3xl") are not. Left out of the search report only, and only when the search found * nothing: the search itself and the owner's rules still take the term. */ public static function mash( string $term ) : bool { foreach ( ( preg_split( '/[^\\p{L}]+/u', mb_strtolower( $term ), -1, PREG_SPLIT_NO_EMPTY ) ?: array() ) as $word ) { if ( preg_match( '/^x*[sml]$/', $word ) ) { continue; } if ( preg_match( '/^[a-zçğışöü]+$/u', $word ) && (mb_strlen( $word ) >= 4 && !preg_match( '/[aeıioöuüy]/u', $word ) || preg_match( '/[bcçdfgğhjklmnpqrsştvwxz]{5}/u', $word )) ) { return true; } } return false; } private static function all() : array { $saved = get_option( self::OPTION, array() ); return ( is_array( $saved ) ? $saved : array() ); } private static function prune( array $all ) : array { $cut = time() - self::DAYS * DAY_IN_SECONDS; // A term kept before a rule was tightened (a long number in other digits, before 0.57) goes as well. $all = array_filter( $all, static fn( $row ) => is_array( $row ) && (int) ($row['last'] ?? 0) >= $cut && self::clean( (string) ($row['term'] ?? '') ) !== '' && (array_sum( array_column( (array) ($row['days'] ?? array()), 'results' ) ) > 0 || !self::mash( (string) ($row['term'] ?? '') )) ); $day_cut = gmdate( 'Y-m-d', time() - 29 * DAY_IN_SECONDS ); foreach ( $all as &$row ) { if ( isset( $row['days'] ) ) { $row['days'] = array_filter( (array) $row['days'], static fn( $v, $date ) => is_array( $v ) && preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/D', (string) $date ) && $date >= $day_cut && $date <= gmdate( 'Y-m-d' ), ARRAY_FILTER_USE_BOTH ); if ( (int) ($row['legacy_last'] ?? 0) < $cut ) { unset($row['legacy_count'], $row['legacy_last']); } $row['count'] = (int) ($row['legacy_count'] ?? 0) + array_sum( array_column( $row['days'], 'count' ) ); } } unset($row); // The most searched terms keep their places, the latest searches get the rest (S18): a flood of one-off terms can // take the latest places only, never push out what shoppers keep asking for, and a new term still gets in. uasort( $all, static fn( $a, $b ) => array((int) $b['count'], (int) $b['last']) <=> array((int) $a['count'], (int) $a['last']) ); $kept = array_slice( $all, 0, self::MAX_TERMS - self::RECENT_TERMS, true ); $rest = array_diff_key( $all, $kept ); uasort( $rest, static fn( $a, $b ) => (int) $b['last'] <=> (int) $a['last'] ); return $kept + array_slice( $rest, 0, self::RECENT_TERMS, true ); } /** Most searched terms of the last 30 days that the owner has not dismissed since they were last searched. */ public static function read() : array { Store::require_cap( 'manage_woocommerce' ); // What pruning leaves out (old, or refused by today's rules) is also removed from the stored list, not only hidden. $kept = self::cleanup(); $rows = array_filter( $kept, static fn( $row ) => self::misses( $row ) > 0 && (int) ($row['hidden'] ?? 0) < (int) ($row['last_miss'] ?? $row['legacy_last'] ?? $row['last']) ); uasort( $rows, static fn( $a, $b ) => array((int) $b['count'], (int) $b['last']) <=> array((int) $a['count'], (int) $a['last']) ); $out = array(); foreach ( array_slice( $rows, 0, 30 ) as $row ) { $out[] = array( 'term' => $row['term'], 'count' => self::misses( $row ), 'last' => wp_date( get_option( 'date_format' ), (int) ($row['last_miss'] ?? $row['last']) ), 'search' => admin_url( 'edit.php?post_type=product&s=' . rawurlencode( $row['term'] ) ), ); } return array( 'terms' => $out, ); } /** Hides a term until someone searches for it again. */ public static function dismiss( string $term ) : array { Store::require_cap( 'manage_woocommerce' ); self::locked( 'counts', static function () use($term) { $all = self::all(); $key = mb_strtolower( trim( $term ) ); if ( isset( $all[$key] ) ) { $all[$key]['hidden'] = time(); update_option( self::OPTION, $all, false ); } } ); return self::read(); } private static function misses( array $row ) : int { return ( isset( $row['days'] ) ? (int) ($row['legacy_count'] ?? 0) + (int) array_sum( array_column( $row['days'], 'misses' ) ) : (int) $row['count'] ); } private static function locked( string $part, callable $fn, $busy = null ) { global $wpdb; $key = substr( 'ss-search-' . hash( 'sha256', DB_NAME . ':' . $wpdb->prefix . ':' . $part ), 0, 64 ); // Server-wide lock names: scoped to this database (0.62.9); MySQL refuses names over 64 characters (0.62.10). if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $key ) ) !== 1 ) { if ( $busy === false ) { return false; } throw new \RuntimeException(I18n::t( 'Search settings are busy. Try again.' )); } try { wp_cache_delete( ( $part === 'counts' ? self::OPTION : self::RULES ), 'options' ); return $fn(); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $key ) ); } } public static function cleanup() : array { $saved = self::locked( 'counts', static function () { $all = self::all(); $kept = self::prune( $all ); if ( $kept !== $all ) { update_option( self::OPTION, $kept, false ); } return $kept; }, false ); return ( is_array( $saved ) ? $saved : self::prune( self::all() ) ); } public static function before_store( $response, $handler, \WP_REST_Request $request ) { if ( $response === null && $request->get_method() === 'GET' && preg_match( '#^/wc/store/v\\d+/products/?$#D', $request->get_route() ) && self::clean( (string) $request->get_param( 'search' ) ) !== '' ) { self::$requests[spl_object_id( $request )] = $request; } return $response; } public static function after_store( $response, $handler, \WP_REST_Request $request ) { $key = spl_object_id( $request ); if ( !isset( self::$requests[$key] ) ) { return $response; } unset(self::$requests[$key]); if ( !current_user_can( 'edit_posts' ) && self::shopper() && (int) $request->get_param( 'page' ) <= 1 && (int) $request->get_param( 'offset' ) === 0 && $response instanceof \WP_REST_Response && $response->get_status() === 200 ) { $headers = array_change_key_case( $response->get_headers(), CASE_LOWER ); if ( isset( $headers['x-wp-total'] ) ) { self::add( (string) $request->get_param( 'search' ), (int) $headers['x-wp-total'] ); } } return $response; } }