, text: string, tags: string[]}> */ private static array $cache = []; private const STOP = [ // English 'the','and','for','with','from','your','you','this','that','are','was','were','has','have','had','its','our','their', 'into','over','more','most','best','guide','what','when','where','which','how','why','about','all','any','can','will', 'visit','visiting','things','top','also','just','than','then','them','they','there','here','one','two','new','near', // Swedish 'och','för','med','som','det','den','att','till','från','på','av','är','var','har','inte','ett','en','om','eller', 'men','kan','ska','vid','över','efter','under','mot','alla','sin','sina','dess','denna','detta','dessa', 'här','där','när','hur','vad','vem','sedan','också','bara','mycket','många','finns','blir','kommer','även','eller','samt','inom','mellan','utan','sig','man','den','det','de','vi','ni','han','hon', 'would','could','should','into','onto','very','much','many','some','such','only','other','there','these','those','where', ]; /** Weighted word profile of a post (cached per request). */ private static function profile( int $post_id ): array { if ( isset( self::$cache[ $post_id ] ) ) return self::$cache[ $post_id ]; $post = get_post( $post_id ); if ( ! $post ) return self::$cache[ $post_id ] = [ 'w' => [], 'text' => '', 'tags' => [] ]; $plain = wp_strip_all_tags( strip_shortcodes( $post->post_content ) ); $plain = html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $open = implode( ' ', array_slice( preg_split( '/\s+/u', $post->post_excerpt . ' ' . $plain ), 0, 120 ) ); $title = html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $tags = get_the_terms( $post_id, 'post_tag' ); // cached (wp_get_post_terms() queries every time) $tags = ( ! $tags || is_wp_error( $tags ) ) ? [] : array_map( fn( $t ) => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), $tags ); $w = []; foreach ( [ [ $title, 3.0 ], [ implode( ' ', $tags ), 2.0 ], [ $open, 1.0 ] ] as [ $txt, $weight ] ) { foreach ( self::words( $txt ) as $word ) { $w[ $word ] = ( $w[ $word ] ?? 0 ) + $weight; } } return self::$cache[ $post_id ] = [ 'w' => $w, 'text' => mb_strtolower( $title . ' ' . $plain, 'UTF-8' ), 'tags' => $tags ]; } /** @return string[] */ private static function words( string $txt ): array { preg_match_all( '/[\p{L}\p{N}]{3,}/u', mb_strtolower( $txt, 'UTF-8' ), $m ); return array_values( array_filter( $m[0], fn( $w ) => ! in_array( $w, self::STOP, true ) && ! ctype_digit( $w ) ) ); } /** @var array|null word → number of site titles containing it */ private static ?array $df = null; private static int $titles = 0; /** How many published titles contain each word (once per request). */ private static function df( string $word ): int { if ( self::$df === null ) { global $wpdb; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $rows = $wpdb->get_col( $wpdb->prepare( 'SELECT post_title FROM %i WHERE ' . Scope::type_sql() . ' AND ' . Scope::sql(), $wpdb->posts ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- titles once per request; type_sql() is prepared, sql() has no values // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared self::$titles = count( $rows ); self::$df = []; foreach ( $rows as $t ) { foreach ( array_unique( self::words( html_entity_decode( $t, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ) as $w ) { self::$df[ $w ] = ( self::$df[ $w ] ?? 0 ) + 1; } } } return self::$df[ $word ] ?? 0; } /** A word is distinctive when few titles on the site use it ("Alvik" yes, "Hotels"/"Stockholm" no). */ private static function distinctive( string $word ): bool { self::df( $word ); return self::df( $word ) <= max( 3, (int) ceil( self::$titles * 0.04 ) ); } /** * The target's distinctive name words, as written in its title * ("Hotels in Alvik: Best Stays…" → ["Alvik"]). * * @return string[] */ private static function subject_words( int $post_id ): array { $t = html_entity_decode( get_the_title( $post_id ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $t = trim( preg_split( '/\s*[:–—|(]\s*|\s+-\s+/u', $t )[0] ?? $t ); preg_match_all( '/[\p{L}\p{N}][\p{L}\p{N}’\'-]{2,}/u', $t, $m ); $out = []; foreach ( $m[0] as $w ) { $lw = mb_strtolower( $w, 'UTF-8' ); if ( in_array( $lw, self::STOP, true ) || mb_strlen( $lw ) < 4 ) continue; if ( self::distinctive( $lw ) ) $out[] = $w; } return $out; } /** * How well $source fits as a place to link to $target. * * @return array{score:int, reasons:string[]} */ public static function score( int $source_id, int $target_id ): array { $a = self::profile( $source_id ); $b = self::profile( $target_id ); $reasons = []; // Cosine-style overlap of weighted word profiles. $dot = 0.0; $shared = []; foreach ( $b['w'] as $word => $wb ) { if ( isset( $a['w'][ $word ] ) ) { $dot += min( $a['w'][ $word ], $wb ); $shared[ $word ] = $a['w'][ $word ] * $wb; } } $na = array_sum( $a['w'] ); $nb = array_sum( $b['w'] ); $overlap = ( $na > 0 && $nb > 0 ) ? $dot / sqrt( $na * $nb ) : 0.0; // ~0–1 // Does the source already name the target's subject (its distinctive words)? $named = []; foreach ( self::subject_words( $target_id ) as $w ) { if ( preg_match( '/(? self::distinctive( $w ), ARRAY_FILTER_USE_BOTH ) ), 0, 3 ); if ( $top ) $reasons[] = sprintf( /* translators: %s: text */ __( 'Both about %s', 'clusterlane' ), implode( ', ', $top ) ); } $score = min( 1.0, $overlap * 2.2 ) * 60 + ( $mentions ? 30 : 0 ) + min( 10, count( $tags ) * 5 ); return [ 'score' => (int) round( min( 100, $score ) ), 'reasons' => $reasons ]; } }