PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / SpellCheckerTrait_CandidateFiltering.php

SpellCheckerTrait_CandidateFiltering.php in 404 Solution 4.1.19, at includes/SpellCheckerTrait_CandidateFiltering.php

519 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Candidate filtering, scoring, and matching on posts/tags/categories
9 * for ABJ_404_Solution_SpellChecker.
10 */
11 trait SpellCheckerTrait_CandidateFiltering {
12
13 /** Returns a list of matching posts.
14 * @param string $requestedURLRaw
15 * @param string $includeCats
16 * @param string $includeTags
17 * @return array<int, mixed>
18 */
19 function findMatchingPosts(string $requestedURLRaw, string $includeCats = '1', string $includeTags = '1') {
20
21 $options = $this->logic->getOptions();
22 // the number of pages to cache is (max suggestions) + (the number of exclude pages).
23 // (if either of these numbers increases then we need to clear the spelling cache.)
24 $excludePagesCount = 0;
25 $excludePagesRaw = isset($options['excludePages[]']) && is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
26 if (trim($excludePagesRaw) !== '') {
27 $jsonResult = json_decode($excludePagesRaw);
28 if (!is_array($jsonResult)) {
29 $jsonResult = array($jsonResult);
30 }
31 $excludePagesCount = count($jsonResult);
32 }
33 $suggestMaxRaw = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
34 $maxCacheCount = absint($suggestMaxRaw) + $excludePagesCount;
35
36 $requestedURLSpaces = $this->f->str_replace($this->separatingCharacters, " ", $requestedURLRaw);
37 $requestedURLCleaned = $this->getLastURLPart($requestedURLSpaces);
38 $fullURLspacesCleaned = $this->f->str_replace('/', " ", $requestedURLSpaces);
39 // if there is no extra stuff in the path then we ignore this to save time.
40 if ($fullURLspacesCleaned == $requestedURLCleaned) {
41 $fullURLspacesCleaned = '';
42 }
43
44 // prepare to get some posts.
45 $this->initializePublishedPostsProvider();
46
47 $rowType = 'pages';
48 $permalinks = array();
49 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - match on posts
50 $permalinks = $this->matchOnPosts($permalinks, $requestedURLRaw, $requestedURLCleaned,
51 $fullURLspacesCleaned, $rowType);
52
53 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - match on tags
54 // search for a similar tag.
55 if ($includeTags == "1") {
56 $permalinks = $this->matchOnTags($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, 'tags');
57 }
58
59 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - match on categories
60 // search for a similar category.
61 if ($includeCats == "1") {
62 $permalinks = $this->matchOnCats($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, 'categories');
63 }
64
65 // remove excluded pages
66 $permalinks = $this->removeExcludedPages($options, $permalinks);
67
68 // This is sorted so that the link with the highest score will be first when iterating through.
69 arsort($permalinks);
70
71 $permalinks = $this->removeExcludedPagesWithRegex($options, $permalinks, $maxCacheCount);
72
73 // only keep what we need. store them for later if necessary.
74 $permalinks = array_splice($permalinks, 0, $maxCacheCount);
75
76 $returnValue = array($permalinks,$rowType);
77 $this->dao->storeSpellingPermalinksToCache($requestedURLRaw, $returnValue);
78 $ctx = abj_service('request_context');
79 $ctx->permalinks_found = (string)json_encode($returnValue);
80 $ctx->permalinks_kept = (string)json_encode($permalinks);
81
82 return $returnValue;
83 }
84
85 /**
86 * @param array<string, mixed> $options
87 * @param array<string, string> $permalinks
88 * @return array<string, string>
89 */
90 function removeExcludedPages(array $options, array $permalinks): array {
91 $excludePagesJsonRaw = isset($options['excludePages[]']) ? $options['excludePages[]'] : '';
92 $excludePagesJson = is_string($excludePagesJsonRaw) ? $excludePagesJsonRaw : '';
93 if (trim($excludePagesJson) == '' && $this->custom404PageID == null) {
94 return $permalinks;
95 }
96
97 // look at every ID to exclude.
98 $excludePages = json_decode($excludePagesJson);
99 if (!is_array($excludePages)) {
100 $excludePages = array($excludePages);
101 }
102
103 // don't include the user specified 404 page in the spelling results..
104 if ($this->custom404PageID != null) {
105 array_push($excludePages, $this->custom404PageID);
106 }
107
108 for ($i = 0; $i < count($excludePages); $i++) {
109 $excludePage = $excludePages[$i];
110 if ($excludePage == null || trim($excludePage) == '') {
111 continue;
112 }
113 // remove it from the results list.
114 // Entry format matches permalink key format: "id|type" (e.g. "42|1").
115 unset($permalinks[(string)$excludePage]);
116 }
117
118 return $permalinks;
119 }
120
121 /**
122 * Removes permalink suggestions if their URL path matches exclusion regex patterns.
123 *
124 * @param array<string, mixed> $options Plugin options containing 'suggest_regex_exclusions_usable'.
125 * @param array<string, string> $permalinks An array where keys are "ID|TYPE_CONSTANT" and values are scores.
126 * @param int $maxCacheCount
127 * @return array<string, string> The filtered $permalinks array.
128 */
129 function removeExcludedPagesWithRegex(array $options, array $permalinks, int $maxCacheCount): array {
130 // Check if usable regex patterns exist and are in an array format
131 if (!isset($options['suggest_regex_exclusions_usable']) ||
132 !is_array($options['suggest_regex_exclusions_usable']) ||
133 empty($options['suggest_regex_exclusions_usable'])) {
134 // No patterns to apply, return original list
135 return $permalinks;
136 }
137
138 $suggestionsKeptSoFar = 0;
139 $regexExclusions = $options['suggest_regex_exclusions_usable'];
140
141 // Iterate through each permalink entry using keys directly
142 // Modifying array while iterating requires careful handling, using keys is safer.
143 $keys_to_check = array_keys($permalinks);
144
145 foreach ($keys_to_check as $key) {
146 // Skip if the key somehow got removed in a previous iteration (shouldn't happen here)
147 if (!array_key_exists($key, $permalinks)) {
148 continue;
149 }
150
151 // Split the key into ID and Type Constant
152 $keyParts = explode('|', $key);
153 if (count($keyParts) !== 2 || !is_numeric($keyParts[0])) {
154 $this->logger->debugMessage("Skipping invalid key format in removeExcludedPagesWithRegex: " . $key);
155 continue; // Skip invalid keys
156 }
157
158 $id = (int)$keyParts[0];
159 $typeConstant = $keyParts[1]; // Keep as string/int as needed by mapTypeConstantToString
160
161 // Map the type constant (e.g., '1') to the string type ('pages', 'tags', etc.)
162 $rowTypeString = $this->mapTypeConstantToString($typeConstant);
163 if ($rowTypeString === null) {
164 $this->logger->debugMessage("Skipping unknown type constant in removeExcludedPagesWithRegex: " . $typeConstant . " for key: " . $key);
165 continue; // Skip unknown types
166 }
167
168 // Get the full URL using the class's method (handles cache)
169 $urlOfPage = $this->getPermalink($id, $rowTypeString);
170 if ($urlOfPage === null || trim($urlOfPage) === '') {
171 $this->logger->debugMessage("Skipping null/empty URL for key in removeExcludedPagesWithRegex: " . $key);
172 continue; // Skip if URL couldn't be retrieved
173 }
174
175 // Parse the URL and get the path, remove home directory if needed (consistency)
176 $urlParts = parse_url($urlOfPage);
177 if (!is_array($urlParts) || !isset($urlParts['path'])) {
178 $this->logger->debugMessage("Skipping URL that failed parse_url for key in removeExcludedPagesWithRegex: " . $key . ", URL: " . esc_url($urlOfPage));
179 continue; // Skip invalid URLs
180 }
181 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
182 // Ensure path starts with / for consistency if it's not empty
183 if ( $pathOnly !== '' && substr($pathOnly, 0, 1) !== '/' ) {
184 $pathOnly = '/' . $pathOnly;
185 }
186 // Handle case where path might be empty (e.g., homepage) which results in '/'
187 if ( $pathOnly === '' ) {
188 $pathOnly = '/';
189 }
190
191 $stringToMatch = $pathOnly; // The string we will match the regex against
192
193 $kept = true;
194 // Check against each exclusion pattern
195 foreach ($regexExclusions as $pattern) {
196 // Remove slashes like in the example provided for folders_files_ignore
197 $patternToExcludeNoSlashes = stripslashes($pattern);
198 $matches = array(); // Variable for the match results
199
200 // Use the class's regexMatch function
201 if ($this->f->regexMatch($patternToExcludeNoSlashes, $stringToMatch, $matches)) {
202 // Pattern matched, remove this permalink from the list
203 unset($permalinks[$key]);
204 $this->logger->debugMessage("Regex excluded suggestion. Key: " . $key .
205 ", Path: '" . esc_html($stringToMatch) . "', Pattern: '" . esc_html($patternToExcludeNoSlashes) . "'");
206 $kept = false;
207 // Break the inner loop (patterns), move to the next permalink key
208 break;
209 }
210 }
211
212 // track how many suggestions we actually need and stop filtering after we reach that count
213 if ($kept) {
214 $suggestionsKeptSoFar++;
215 }
216 if ($suggestionsKeptSoFar >= $maxCacheCount) {
217 break;
218 }
219 }
220
221 return $permalinks;
222 }
223
224 /**
225 * Maps internal type constants to string identifiers used by getPermalink.
226 * NOTE: Requires ABJ404_TYPE_* constants to be defined correctly.
227 *
228 * @param mixed $typeConstant The type constant (e.g., ABJ404_TYPE_POST).
229 * @return string|null The string identifier ('pages', 'tags', 'categories') or null if not found.
230 */
231 private function mapTypeConstantToString($typeConstant) {
232 // Define these constants if they are not globally available or use their actual values
233 if (!defined('ABJ404_TYPE_POST')) define('ABJ404_TYPE_POST', 1);
234 if (!defined('ABJ404_TYPE_CAT')) define('ABJ404_TYPE_CAT', 2);
235 if (!defined('ABJ404_TYPE_TAG')) define('ABJ404_TYPE_TAG', 3);
236 // Add other types like ABJ404_TYPE_IMAGE if needed
237
238 $typeConstantStr = is_scalar($typeConstant) ? (string)$typeConstant : '';
239 switch ($typeConstantStr) { // Cast to string for reliable comparison if needed
240 case ABJ404_TYPE_POST:
241 return 'pages'; // Based on getPermalink implementation which uses 'pages' for posts
242 case ABJ404_TYPE_TAG:
243 return 'tags';
244 case ABJ404_TYPE_CAT:
245 return 'categories';
246 // Add 'image' case if ABJ404_TYPE_IMAGE exists and is used in $permalinks keys
247 // case ABJ404_TYPE_IMAGE:
248 // return 'image';
249 default:
250 // Log or handle unknown type
251 return null;
252 }
253 }
254
255 /**
256 * @param array<string, string> $permalinks
257 * @param string $requestedURLCleaned
258 * @param string $fullURLspacesCleaned
259 * @param string $rowType
260 * @return array<string, string>
261 */
262 function matchOnCats(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
263
264 $rows = $this->dao->getPublishedCategories();
265 $rows = $this->getOnlyIDandTermID($rows);
266
267 // pre-filter some pages based on the min and max possible levenshtein distances.
268 $likelyMatchIDsAndPermalinks = $this->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, 'categories', $rows);
269 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
270
271 // Early termination optimization
272 $options = $this->logic->getOptions();
273 $suggestMaxRaw = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
274 $suggestMax = absint($suggestMaxRaw);
275 $topKScores = new SplMinHeap();
276 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
277
278 // access the array directly instead of using a foreach loop so we can remove items
279 // from the end of the array in the middle of the loop.
280 foreach ($likelyMatchIDs as $id) {
281 // use the levenshtein distance formula here.
282 $the_permalink = $this->getPermalink((int)$id, 'categories');
283 $urlParts = parse_url(is_string($the_permalink) ? $the_permalink : '');
284 if (!is_array($urlParts) || !isset($urlParts['path'])) {
285 continue;
286 }
287 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
288 $scoreBasis = $this->f->strlen($pathOnly);
289 if ($scoreBasis == 0) {
290 continue;
291 }
292
293 // EARLY TERMINATION: Check if this candidate can possibly beat our worst current match
294 if ($topKScores->count() >= $suggestMax) {
295 $worstAcceptableScore = $topKScores->top();
296
297 // OPTIMIZATION 3: Levenshtein distance threshold pruning
298 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
299 $pathOnlyLength = $this->f->strlen($pathOnly);
300 $minPossibleDistance = abs($requestedURLCleanedLength - $pathOnlyLength);
301
302 if ($minPossibleDistance > $maxAllowedLevenshtein) {
303 continue; // Can't possibly beat worst score in heap
304 }
305 }
306
307 $levscore = $this->customLevenshtein($requestedURLCleaned, $pathOnly);
308
309 // OPTIMIZATION 2: Lazy evaluation of fullURLspacesCleaned
310 if ($fullURLspacesCleaned != '') {
311 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
312 if ($tentativeScore < 95) {
313 $pathOnlySpaces = $this->f->str_replace($this->separatingCharacters, " ", $pathOnly);
314 $pathOnlySpaces = trim($this->f->str_replace('/', " ", $pathOnlySpaces));
315 $levscore = min($levscore, $this->customLevenshtein($fullURLspacesCleaned, $pathOnlySpaces));
316 }
317 }
318
319 $onlyLastPart = $this->getLastURLPart($pathOnly);
320 if ($onlyLastPart != '' && $onlyLastPart != $pathOnly) {
321 $levscore = min($levscore, $this->customLevenshtein($requestedURLCleaned, $onlyLastPart));
322 }
323
324 $score = 100 - (($levscore / $scoreBasis) * 100);
325 $permalinks[$id . "|" . ABJ404_TYPE_CAT] = number_format($score, 4, '.', '');
326
327 // Update top-K heap
328 $topKScores->insert($score);
329 if ($topKScores->count() > $suggestMax) {
330 $topKScores->extract();
331 }
332 }
333
334 return $permalinks;
335 }
336
337 /**
338 * @param array<string, string> $permalinks
339 * @param string $requestedURLCleaned
340 * @param string $fullURLspacesCleaned
341 * @param string $rowType
342 * @return array<string, string>
343 */
344 function matchOnTags(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
345
346 $rows = $this->dao->getPublishedTags();
347 $rows = $this->getOnlyIDandTermID($rows);
348
349 // pre-filter some pages based on the min and max possible levenshtein distances.
350 $likelyMatchIDsAndPermalinks = $this->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, 'tags', $rows);
351 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
352
353 // Early termination optimization
354 $options = $this->logic->getOptions();
355 $suggestMaxRawT = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
356 $suggestMax = absint($suggestMaxRawT);
357 $topKScores = new SplMinHeap();
358 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
359
360 // access the array directly instead of using a foreach loop so we can remove items
361 // from the end of the array in the middle of the loop.
362 foreach ($likelyMatchIDs as $id) {
363 // use the levenshtein distance formula here.
364 $the_permalink = $this->getPermalink((int)$id, 'tags');
365 $urlParts = parse_url(is_string($the_permalink) ? $the_permalink : '');
366 if (!is_array($urlParts) || !isset($urlParts['path'])) {
367 continue;
368 }
369 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
370 $scoreBasis = $this->f->strlen($pathOnly);
371 if ($scoreBasis == 0) {
372 continue;
373 }
374
375 // EARLY TERMINATION: Check if this candidate can possibly beat our worst current match
376 if ($topKScores->count() >= $suggestMax) {
377 $worstAcceptableScore = $topKScores->top();
378
379 // OPTIMIZATION 3: Levenshtein distance threshold pruning
380 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
381 $pathOnlyLength = $this->f->strlen($pathOnly);
382 $minPossibleDistance = abs($requestedURLCleanedLength - $pathOnlyLength);
383
384 if ($minPossibleDistance > $maxAllowedLevenshtein) {
385 continue; // Can't possibly beat worst score in heap
386 }
387 }
388
389 $levscore = $this->customLevenshtein($requestedURLCleaned, $pathOnly);
390
391 // OPTIMIZATION 2: Lazy evaluation of fullURLspacesCleaned
392 if ($fullURLspacesCleaned != '') {
393 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
394 if ($tentativeScore < 95) {
395 $pathOnlySpaces = $this->f->str_replace($this->separatingCharacters, " ", $pathOnly);
396 $pathOnlySpaces = trim($this->f->str_replace('/', " ", $pathOnlySpaces));
397 $levscore = min($levscore, $this->customLevenshtein($fullURLspacesCleaned, $pathOnlySpaces));
398 }
399 }
400 $score = 100 - (($levscore / $scoreBasis) * 100);
401 $permalinks[$id . "|" . ABJ404_TYPE_TAG] = number_format($score, 4, '.', '');
402
403 // Update top-K heap
404 $topKScores->insert($score);
405 if ($topKScores->count() > $suggestMax) {
406 $topKScores->extract();
407 }
408 }
409
410 return $permalinks;
411 }
412
413 /**
414 * @param array<string, string> $permalinks
415 * @param string $requestedURLRaw
416 * @param string $requestedURLCleaned
417 * @param string $fullURLspacesCleaned
418 * @param string $rowType
419 * @return array<string, string>
420 */
421 function matchOnPosts(array $permalinks, string $requestedURLRaw, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
422
423 // pre-filter some pages based on the min and max possible levenshtein distances.
424 $likelyMatchIDsAndPermalinks = $this->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, $rowType);
425 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
426
427 $this->logger->debugMessage("Found " . count($likelyMatchIDs) . " likely match IDs.");
428
429 // Early termination optimization: maintain a min-heap of top-K scores
430 // Once we have K matches, we can skip candidates that can't beat the worst in heap
431 $options = $this->logic->getOptions();
432 $suggestMaxRawP = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
433 $suggestMax = absint($suggestMaxRawP);
434 $topKScores = new SplMinHeap(); // Min-heap: smallest score at top
435 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
436
437 // Process candidates in order of best match first (smallest minDist first)
438 // This is critical for early termination: filling the heap with good scores early
439 // allows us to skip more candidates later
440 while (count($likelyMatchIDs) > 0) {
441 $id = array_shift($likelyMatchIDs); // Take from beginning (best matches first)
442
443 // use the levenshtein distance formula here.
444 $the_permalink = $likelyMatchIDsAndPermalinks[$id];
445 $thePermalinkStr = is_string($the_permalink) ? $the_permalink : '';
446 $urlParts = parse_url($thePermalinkStr);
447 if (!is_array($urlParts) || !isset($urlParts['path'])) {
448 continue;
449 }
450 $existingPageURL = $this->logic->removeHomeDirectory($urlParts['path']);
451 $existingPageURLSpaces = $this->f->str_replace($this->separatingCharacters, " ", $existingPageURL);
452
453 $existingPageURLCleaned = $this->getLastURLPart($existingPageURLSpaces);
454 $scoreBasis = $this->f->strlen($existingPageURLCleaned) * 3;
455 if ($scoreBasis == 0) {
456 continue;
457 }
458
459 // EARLY TERMINATION: Check if this candidate can possibly beat our worst current match
460 if ($topKScores->count() >= $suggestMax) {
461 $worstAcceptableScore = $topKScores->top();
462
463 // OPTIMIZATION 3: Levenshtein distance threshold pruning
464 // Calculate maximum Levenshtein distance that could still beat worstAcceptableScore
465 // Formula: score = 100 - ((lev / scoreBasis) * 100)
466 // Solving for lev: lev = (100 - score) * scoreBasis / 100
467 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
468
469 // Calculate minimum possible distance based on length difference
470 $existingURLCleanedLength = $this->f->strlen($existingPageURLCleaned);
471 $minPossibleDistance = abs($requestedURLCleanedLength - $existingURLCleanedLength);
472
473 // If minimum possible distance already exceeds threshold, skip
474 if ($minPossibleDistance > $maxAllowedLevenshtein) {
475 continue; // Can't possibly beat worst score in heap
476 }
477 }
478
479 $levscore = $this->customLevenshtein($requestedURLCleaned, $existingPageURLCleaned);
480
481 // OPTIMIZATION 2: Lazy evaluation of fullURLspacesCleaned (10-20% reduction)
482 // Only try the second comparison if the first score isn't already excellent (>95)
483 if ($fullURLspacesCleaned != '') {
484 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
485 if ($tentativeScore < 95) {
486 $levscore = min($levscore, $this->customLevenshtein($fullURLspacesCleaned, $existingPageURLCleaned));
487 }
488 }
489
490 if ($rowType == 'image') {
491 // strip the image size from the file name and try again.
492 // the image size is at the end of the file in the format of -640x480
493 $strippedImageName = $this->f->regexReplace('(.+)([-]\d{1,5}[x]\d{1,5})([.].+)',
494 '\\1\\3', $requestedURLRaw);
495
496 if (($strippedImageName != null) && ($strippedImageName != $requestedURLRaw)) {
497 $strippedImageName = $this->f->str_replace($this->separatingCharactersForImages, " ", $strippedImageName);
498 $levscore = min($levscore, $this->customLevenshtein($strippedImageName, $existingPageURL));
499
500 $strippedImageName = $this->getLastURLPart($strippedImageName);
501 $levscore = min($levscore, $this->customLevenshtein($strippedImageName, $existingPageURLCleaned));
502 }
503 }
504 $score = 100 - (($levscore / $scoreBasis) * 100);
505 $permalinks[$id . "|" . ABJ404_TYPE_POST] = number_format($score, 4, '.', '');
506
507 // Update top-K heap with this score
508 $topKScores->insert($score);
509 // Keep heap size at most suggestMax (remove worst if exceeded)
510 if ($topKScores->count() > $suggestMax) {
511 $topKScores->extract(); // Remove the smallest (worst) score
512 }
513 }
514
515 return $permalinks;
516 }
517
518 }
519