• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / Yoast-SEO-for-TYPO3 / 28811642395

06 Jul 2026 05:47PM UTC coverage: 20.835% (-0.3%) from 21.137%
28811642395

push

github

RinyVT
[BUGFIX] Optimize prominent words and linking suggestions performance, prevent prominent words to be copied when pages are copied

16 of 17 new or added lines in 2 files covered. (94.12%)

569 of 2731 relevant lines covered (20.83%)

0.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.86
/Classes/Service/LinkingSuggestionsService.php
1
<?php
2

3
/**
4
 * This file is part of the "yoast_seo" extension for TYPO3 CMS.
5
 *
6
 * For the full copyright and license information, please read the
7
 * LICENSE.txt file that was distributed with this source code.
8
 */
9

10
declare(strict_types=1);
11

12
namespace YoastSeoForTypo3\YoastSeo\Service;
13

14
use TYPO3\CMS\Backend\Utility\BackendUtility;
15
use TYPO3\CMS\Core\Context\LanguageAspect;
16
use TYPO3\CMS\Core\Database\Connection;
17
use TYPO3\CMS\Core\Database\ConnectionPool;
18
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
19
use TYPO3\CMS\Core\Utility\GeneralUtility;
20
use YoastSeoForTypo3\YoastSeo\Constants\TableNames;
21
use YoastSeoForTypo3\YoastSeo\Traits\LanguageServiceTrait;
22

23
class LinkingSuggestionsService
24
{
25
    use LanguageServiceTrait;
26

27
    protected int $excludePageId;
28
    protected int $site;
29
    protected int $languageId;
30

31
    /**
32
     * Document frequency of every stem of the current site and language,
33
     * loaded once per scoring run by {@see self::loadDocumentFrequencies()}.
34
     *
35
     * @var array<string, int>
36
     */
37
    protected array $documentFrequencyCache = [];
38

39
    public function __construct(
40
        protected ConnectionPool $connectionPool,
41
        protected PageRepository $pageRepository,
42
        protected SiteService $siteService,
43
        protected int $batchSize = 100,
44
    ) {}
3✔
45

46
    /**
47
     * @param array<array{occurrences: int, stem: string}> $words
48
     * @return array<array<string, mixed>>
49
     */
50
    public function getLinkingSuggestions(
51
        array $words,
52
        int $excludePageId,
53
        int $languageId,
54
        string $content
55
    ): array {
56
        if ($words === []) {
×
57
            return [];
×
58
        }
59
        $this->excludePageId = $excludePageId;
×
60
        $this->site = $this->siteService->getSiteRootPageId($excludePageId);
×
61
        $this->languageId = $languageId;
×
62
        $this->documentFrequencyCache = [];
×
63

64
        $scores = $this->collectScores(array_column($words, 'occurrences', 'stem'));
×
65

66
        // Return the empty list if no suggestions have been found.
67
        if ($scores === []) {
×
68
            return [];
×
69
        }
70

71
        return $this->linkRecords($scores, $this->getCurrentContentLinks($content));
×
72
    }
73

74
    /**
75
     * Score every candidate record that shares prominent word stems with the request,
76
     * processing the candidates in chunks of {@see self::$batchSize} record groups.
77
     *
78
     * @param array<string, int|string> $words stem => occurrences
79
     * @return array<string, float|int> record key (uid_foreign-tablenames) => normalized score
80
     */
81
    protected function collectScores(array $words): array
82
    {
83
        $this->loadDocumentFrequencies();
3✔
84

85
        // Combine stems, weights and DFs from request
86
        $requestData = $this->composeRequestData($words);
3✔
87
        if ($requestData === []) {
3✔
NEW
88
            return [];
×
89
        }
90

91
        // Calculate vector length of the request set (needed for score normalization later)
92
        $requestVectorLength = $this->computeVectorLength($requestData);
3✔
93

94
        // Retrieve all (pid, tablenames) record groups that share stems with the request
95
        $recordGroups = $this->findRecordsByStems(array_keys($requestData));
3✔
96

97
        $scores = [];
3✔
98
        foreach (array_chunk($recordGroups, max(1, $this->batchSize)) as $recordGroupBatch) {
3✔
99
            // Expand the groups into their prominent words and index them by record
100
            $candidatesWords = $this->findStemsByRecords($recordGroupBatch);
3✔
101
            $candidatesWordsByRecord = $this->groupWordsByRecord($candidatesWords);
3✔
102

103
            foreach ($candidatesWordsByRecord as $id => $candidateData) {
3✔
104
                $scores[$id] = $this->calculateScoreForIndexable($requestData, $requestVectorLength, $candidateData);
3✔
105
            }
106

107
            // Sort the list of scores and keep only the top of the scores
108
            $scores = $this->getTopSuggestions($scores);
3✔
109
        }
110

111
        return $scores;
3✔
112
    }
113

114
    /**
115
     * @param array<string, int|string> $requestWords
116
     * @return array<string, array{weight: int, df: int}>
117
     */
118
    protected function composeRequestData(array $requestWords): array
119
    {
120
        $requestDocFrequencies = $this->countDocumentFrequencies(array_keys($requestWords));
3✔
121
        $combinedRequestData = [];
3✔
122
        foreach ($requestWords as $stem => $weight) {
3✔
123
            if (!isset($requestDocFrequencies[$stem])) {
3✔
124
                continue;
×
125
            }
126

127
            $combinedRequestData[$stem] = [
3✔
128
                'weight' => (int)$weight,
3✔
129
                'df' => $requestDocFrequencies[$stem],
3✔
130
            ];
3✔
131
        }
132
        return $combinedRequestData;
3✔
133
    }
134

135
    /**
136
     * Load all stems and their document frequencies for the current site and language in one call.
137
     * This used to be done per batch of candidate records, but that was very expensive on large sites with many stems.
138
     * Doing this in one pass is much cheaper, as it can be done with a single index-covered aggregation query.
139
     * using fetchAssociative() to stream the rows instead of buffering them in memory, which saves a lot of memory on large sites.
140
     */
141
    protected function loadDocumentFrequencies(): void
142
    {
143
        $this->documentFrequencyCache = [];
3✔
144

145
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(TableNames::PROMINENT_WORD);
3✔
146
        $result = $queryBuilder->select('stem')->addSelectLiteral('COUNT(stem) AS document_frequency')
3✔
147
            ->from(TableNames::PROMINENT_WORD)
3✔
148
            ->where(
3✔
149
                $queryBuilder->expr()->eq('sys_language_uid', $this->languageId),
3✔
150
                $queryBuilder->expr()->eq('site', $this->site)
3✔
151
            )->groupBy('stem')->executeQuery();
3✔
152

153
        while ($rawDocFrequency = $result->fetchAssociative()) {
3✔
154
            $this->documentFrequencyCache[(string)$rawDocFrequency['stem']] = (int)$rawDocFrequency['document_frequency'];
3✔
155
        }
156
    }
157

158
    /**
159
     * @param string[] $stems
160
     * @return array<string, int>
161
     */
162
    protected function countDocumentFrequencies(array $stems): array
163
    {
164
        $docFrequencies = [];
3✔
165
        foreach ($stems as $stem) {
3✔
166
            if (isset($this->documentFrequencyCache[$stem])) {
3✔
167
                $docFrequencies[$stem] = $this->documentFrequencyCache[$stem];
3✔
168
            }
169
        }
170
        return $docFrequencies;
3✔
171
    }
172

173
    /**
174
     * @param array<string, array{weight: int, df?: int}> $prominentWords
175
     */
176
    protected function computeVectorLength(array $prominentWords): float
177
    {
178
        $sumOfSquares = 0;
3✔
179
        foreach ($prominentWords as $word) {
3✔
180
            $docFrequency = 1;
3✔
181
            if (array_key_exists('df', $word)) {
3✔
182
                $docFrequency = $word['df'];
3✔
183
            }
184

185
            $tfIdf = $this->computeTfIdfScore($word['weight'], $docFrequency);
3✔
186
            $sumOfSquares += ($tfIdf ** 2);
3✔
187
        }
188
        return sqrt($sumOfSquares);
3✔
189
    }
190

191
    protected function computeTfIdfScore(int $termFrequency, int $docFrequency): float
192
    {
193
        $docFrequency = max(1, $docFrequency);
3✔
194
        return $termFrequency * (1 / $docFrequency);
3✔
195
    }
196

197
    /**
198
     * @param array<array{pid: int, tablenames: string}> $records
199
     * @return array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int, df?: int}>
200
     */
201
    protected function findStemsByRecords(array $records): array
202
    {
203
        if ($records === []) {
3✔
204
            return [];
×
205
        }
206

207
        $prominentWords = $this->getProminentWords($records);
3✔
208
        $prominentStems = array_unique(array_column($prominentWords, 'stem'));
3✔
209

210
        $stemCounts = $this->countDocumentFrequencies($prominentStems);
3✔
211

212
        foreach ($prominentWords as &$prominentWord) {
3✔
213
            if (!isset($stemCounts[$prominentWord['stem']])) {
3✔
214
                continue;
×
215
            }
216
            $prominentWord['df'] = $stemCounts[$prominentWord['stem']];
3✔
217
        }
218
        return $prominentWords;
3✔
219
    }
220

221
    /**
222
     * @param string[] $stems
223
     * @return array<array{pid: int, tablenames: string}>
224
     */
225
    protected function findRecordsByStems(array $stems): array
226
    {
227
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(TableNames::PROMINENT_WORD);
3✔
228
        $queryBuilder->select('pid', 'tablenames')->from(TableNames::PROMINENT_WORD)->where(
3✔
229
            $queryBuilder->expr()->in(
3✔
230
                'stem',
3✔
231
                $queryBuilder->createNamedParameter($stems, Connection::PARAM_STR_ARRAY)
3✔
232
            ),
3✔
233
            $queryBuilder->expr()->eq('sys_language_uid', $this->languageId),
3✔
234
            $queryBuilder->expr()->eq('site', $this->site)
3✔
235
        )->groupBy('pid', 'tablenames');
3✔
236
        /** @var array<array{pid: int, tablenames: string}> $records */
237
        $records = $queryBuilder->executeQuery()->fetchAllAssociative();
3✔
238
        return $records;
3✔
239
    }
240

241
    /**
242
     * @param array<array{pid: int, tablenames: string}> $records
243
     * @return array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int}>
244
     */
245
    protected function getProminentWords(array $records): array
246
    {
247
        // Group pids by tablename to use efficient IN() clauses instead of OR chains
248
        $pidsByTable = [];
3✔
249
        foreach ($records as $record) {
3✔
250
            $pidsByTable[$record['tablenames']][] = (int)$record['pid'];
3✔
251
        }
252

253
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(TableNames::PROMINENT_WORD);
3✔
254
        $tableConditions = [];
3✔
255
        foreach ($pidsByTable as $tablename => $pids) {
3✔
256
            $tableConditions[] = $queryBuilder->expr()->and(
3✔
257
                $queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($tablename)),
3✔
258
                $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pids, Connection::PARAM_INT_ARRAY))
3✔
259
            );
3✔
260
        }
261

262
        $queryBuilder->select('stem', 'weight', 'pid', 'tablenames', 'uid_foreign')->from(TableNames::PROMINENT_WORD)
3✔
263
            ->where(
3✔
264
                $queryBuilder->expr()->eq('sys_language_uid', $this->languageId),
3✔
265
                $queryBuilder->expr()->or(...$tableConditions)
3✔
266
            );
3✔
267
        /** @var array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int}> $prominentWords */
268
        $prominentWords = $queryBuilder->executeQuery()->fetchAllAssociative();
3✔
269
        return $prominentWords;
3✔
270
    }
271

272
    /**
273
     * @param array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int, df?: int}> $candidateWords
274
     * @return array<string, array<string, array{weight: int, df: int}>>
275
     */
276
    protected function groupWordsByRecord(array $candidateWords): array
277
    {
278
        $candidateWordsByRecords = [];
3✔
279
        foreach ($candidateWords as $candidateWord) {
3✔
280
            if (!isset($candidateWord['df'])) {
3✔
281
                continue;
×
282
            }
283
            $recordKey = $candidateWord['uid_foreign'] . '-' . $candidateWord['tablenames'];
3✔
284
            $candidateWordsByRecords[$recordKey][$candidateWord['stem']] = [
3✔
285
                'weight' => (int)$candidateWord['weight'],
3✔
286
                'df' => (int)$candidateWord['df'],
3✔
287
            ];
3✔
288
        }
289
        return $candidateWordsByRecords;
3✔
290
    }
291

292
    /**
293
     * @param array<string, array{weight: int, df: int}> $requestData
294
     * @param array<string, array{weight: int, df: int}> $candidateData
295
     */
296
    protected function calculateScoreForIndexable(
297
        array $requestData,
298
        float $requestVectorLength,
299
        array $candidateData
300
    ): float {
301
        $rawScore = $this->computeRawScore($requestData, $candidateData);
3✔
302
        $candidateVectorLength = $this->computeVectorLength($candidateData);
3✔
303
        return $this->normalizeScore($rawScore, $candidateVectorLength, $requestVectorLength);
3✔
304
    }
305

306
    /**
307
     * @param array<string, array{weight: int, df: int}> $requestData
308
     * @param array<string, array{weight: int, df: int}> $candidateData
309
     */
310
    protected function computeRawScore(array $requestData, array $candidateData): float
311
    {
312
        $rawScore = 0;
3✔
313
        foreach ($candidateData as $stem => $candidateWordData) {
3✔
314
            if (!array_key_exists($stem, $requestData)) {
3✔
315
                continue;
2✔
316
            }
317

318
            $wordFromRequestWeight = $requestData[$stem]['weight'];
3✔
319
            $wordFromRequestDf = $requestData[$stem]['df'];
3✔
320
            $candidateWeight = $candidateWordData['weight'];
3✔
321
            $canidateDf = $candidateWordData['df'];
3✔
322

323
            $tfIdfFromRequest = $this->computeTfIdfScore($wordFromRequestWeight, $wordFromRequestDf);
3✔
324
            $tfIdfFromDatabase = $this->computeTfIdfScore($candidateWeight, $canidateDf);
3✔
325

326
            $rawScore += ($tfIdfFromRequest * $tfIdfFromDatabase);
3✔
327
        }
328
        return (float)$rawScore;
3✔
329
    }
330

331
    protected function normalizeScore(float $rawScore, float $vectorLengthCandidate, float $vectorLengthRequest): float
332
    {
333
        $normalizingFactor = $vectorLengthRequest * $vectorLengthCandidate;
3✔
334
        if ($normalizingFactor === 0.0) {
3✔
335
            // We can't divide by 0, so set the score to 0 instead.
336
            return 0;
×
337
        }
338
        return (float)($rawScore / $normalizingFactor);
3✔
339
    }
340

341
    /**
342
     * @param array<string, float|int> $scores
343
     * @return array<string, float|int>
344
     */
345
    protected function getTopSuggestions(array $scores): array
346
    {
347
        // Sort the indexables by descending score.
348
        uasort(
3✔
349
            $scores,
3✔
350
            static function ($score1, $score2) {
3✔
351
                if ($score1 === $score2) {
3✔
352
                    return 0;
1✔
353
                }
354
                return ($score1 < $score2) ? 1 : -1;
2✔
355
            }
3✔
356
        );
3✔
357

358
        // Take the top $limit suggestions, while preserving their ids specified in the keys of the array elements.
359
        return \array_slice($scores, 0, 20, true);
3✔
360
    }
361

362
    /**
363
     * @param array<string, float|int> $scores
364
     * @param array<string, bool> $currentLinks
365
     * @return array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}>
366
     */
367
    protected function linkRecords(array $scores, array $currentLinks): array
368
    {
369
        $links = [];
×
370
        foreach ($scores as $record => $score) {
×
371
            [$uid, $table] = explode('-', $record);
×
372
            if ($table === TableNames::PAGES && (int)$uid === $this->excludePageId) {
×
373
                continue;
×
374
            }
375

376
            $data = BackendUtility::getRecord($table, $uid);
×
377
            if ($data === null) {
×
378
                continue;
×
379
            }
380
            if ($this->languageId > 0 && ($overlay = $this->getRecordOverlay($table, $data, $this->languageId))) {
×
381
                $data = $overlay;
×
382
            }
383

384
            $labelField = $GLOBALS['TCA'][$table]['ctrl']['label'];
×
385

386
            $links[$record] = [
×
387
                'label' => $data[$labelField],
×
388
                'recordType' => $this->getRecordType($table),
×
389
                'id' => (int)$uid,
×
390
                'table' => $table,
×
391
                'cornerstone' => (int)($data['tx_yoastseo_cornerstone'] ?? 0),
×
392
                'score' => $score,
×
393
                'active' => isset($currentLinks[$record]),
×
394
            ];
×
395
        }
396
        $this->sortSuggestions($links);
×
397

398
        $cornerStoneSuggestions = $this->filterSuggestions($links, true);
×
399
        $nonCornerStoneSuggestions = $this->filterSuggestions($links, false);
×
400

401
        return array_merge_recursive([], $cornerStoneSuggestions, $nonCornerStoneSuggestions);
×
402
    }
403

404
    /**
405
     * @param array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}> $links
406
     */
407
    protected function sortSuggestions(array &$links): void
408
    {
409
        uasort(
×
410
            $links,
×
411
            static function ($suggestion1, $suggestion2) {
×
412
                if ($suggestion1['score'] === $suggestion2['score']) {
×
413
                    return 0;
×
414
                }
415

416
                return ($suggestion1['score'] < $suggestion2['score']) ? 1 : -1;
×
417
            }
×
418
        );
×
419
    }
420

421
    /**
422
     * @param array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}> $links
423
     * @return array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}>
424
     */
425
    protected function filterSuggestions(array $links, bool $cornerstone): array
426
    {
427
        return \array_filter(
×
428
            $links,
×
429
            static function ($suggestion) use ($cornerstone) {
×
430
                return (bool)$suggestion['cornerstone'] === $cornerstone;
×
431
            }
×
432
        );
×
433
    }
434

435
    /**
436
     * @return array<string, bool>
437
     */
438
    protected function getCurrentContentLinks(string $content): array
439
    {
440
        $currentLinks = [];
×
441
        preg_match_all('/<a href="t3:\/\/(.*)\?uid=([\d]+)/', $content, $matches, PREG_SET_ORDER);
×
442
        foreach ($matches as $match) {
×
443
            $key = (int)$match[2] . '-' . str_replace('page', TableNames::PAGES, $match[1]);
×
444
            $currentLinks[$key] = true;
×
445
        }
446
        return $currentLinks;
×
447
    }
448

449
    protected function getRecordType(string $table): string
450
    {
451
        return $this->getLanguageService()->sL(
×
452
            $GLOBALS['TCA'][$table]['ctrl']['title']
×
453
        );
×
454
    }
455

456
    /**
457
     * @param array<string, mixed> $data
458
     * @return array<string, mixed>|null
459
     */
460
    protected function getRecordOverlay(string $table, array $data, int $languageId): array|null
461
    {
462
        $languageAspect = GeneralUtility::makeInstance(LanguageAspect::class, $languageId, $languageId, 'mixed');
×
463
        return $this->pageRepository->getLanguageOverlay($table, $data, $languageAspect);
×
464
    }
465
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc