every row's URL against pages.slug (slug = full path) * markets -> level-1.x rows against tx_vitec_domain_model_market * solutions -> level-2.x rows against tx_vitec_domain_model_solution * products -> level-3.x.y+ rows against tx_vitec_domain_model_product * (3.x rows are categories, not products - skipped) * Record matching: slug == last URL segment, falling back to a * normalized title comparison (parentheses stripped, & -> and), and * finally the hand-made record links (record_aliases per model in * tx_vitec_import_mapping - the same store the import tabs use, so * one link serves both views and vitec:create-markets). * - diff against the previous delivery, keyed by URL * * Works on the normalized row schema produced by normalizeRows(). Read-only: * this class never writes anything. */ final class SeoResearchService { private const SECTION_MODELS = [ // Markets/Solutions: every row below the section root is a record // candidate - sub-markets ("Traffic & Smart Mobility" under // "Transport & Infrastructure") and sub-solutions are records of the // same (flat) model. Products: 3.x rows are categories, records // start at 3.x.y. 'Markets' => ['key' => 'market', 'table' => 'tx_vitec_domain_model_market', 'depth' => [2, 9]], 'Solutions' => ['key' => 'solution', 'table' => 'tx_vitec_domain_model_solution', 'depth' => [2, 9]], 'Products' => ['key' => 'product', 'table' => 'tx_vitec_domain_model_product', 'depth' => [3, 9]], ]; /** * Map raw CSV rows (deduplicated headers) onto a stable schema, so stored * deliveries stay comparable even if the CSV gains columns. * * @param array> $raw * @return array> */ public function normalizeRows(array $raw): array { $rows = []; foreach ($raw as $r) { $url = trim((string)($r['Potential URL'] ?? '')); if ($url === '') { continue; } $rows[] = [ 'section' => trim((string)($r['Section'] ?? '')), 'ref' => trim((string)($r['Page Ref'] ?? '')), 'name' => trim((string)($r['Page Name'] ?? '')), 'url' => $url, 'primary' => trim((string)($r['Primary Keyword'] ?? '')), 'volGlobal' => trim((string)($r['Vol (Global)'] ?? '')), 'intent' => trim((string)($r['Intent'] ?? '')), 'kd' => trim((string)($r['KD'] ?? '')), 'gscPos' => trim((string)($r['GSC Pos (blended)'] ?? '')), 'gscImpr' => trim((string)($r['GSC Impr'] ?? '')), 'flag' => trim((string)($r['Flag'] ?? '')), 'secondary' => trim((string)($r['Secondary Keyword'] ?? '')), 'secVolGlobal' => trim((string)($r['Vol (Global)_2'] ?? '')), ]; } return $rows; } /** * @param array> $rows * @param array>|null $previousRows * @return array */ public function analyze(array $rows, ?array $previousRows): array { return [ 'quickWins' => $this->quickWins($rows), 'sharedTerms' => $this->sharedTerms($rows), 'alreadyRanking' => array_values(array_filter($rows, fn(array $r): bool => str_contains($r['flag'], 'Already ranking'))), 'structure' => $this->structure($rows), 'diff' => $previousRows !== null ? $this->diff($previousRows, $rows) : null, ]; } // ------------------------------------------------------------ work lists /** @param array> $rows * @return array> */ private function quickWins(array $rows): array { $wins = array_values(array_filter($rows, fn(array $r): bool => str_contains($r['flag'], 'Quick win'))); usort($wins, static function (array $a, array $b): int { return (int)preg_replace('/\D/', '', $b['gscImpr']) <=> (int)preg_replace('/\D/', '', $a['gscImpr']); }); return $wins; } /** * Shared-term rows grouped by primary keyword - each group is one * cannibalization risk: several pages targeting the same term. * * @param array> $rows * @return array>}> */ private function sharedTerms(array $rows): array { $groups = []; foreach ($rows as $r) { if (!str_contains($r['flag'], 'Shared term')) { continue; } $groups[mb_strtolower($r['primary'])]['keyword'] = $r['primary']; $groups[mb_strtolower($r['primary'])]['pages'][] = $r; } // Pages sharing the keyword without carrying the flag themselves: foreach ($groups as $kw => $group) { foreach ($rows as $r) { if (mb_strtolower($r['primary']) === $kw && !in_array($r, $group['pages'], true)) { $groups[$kw]['pages'][] = $r; } } } return array_values($groups); } // ------------------------------------------------------- structure check /** @param array> $rows * @return array */ private function structure(array $rows): array { $out = ['pages' => $this->pagesCheck($rows)]; foreach (self::SECTION_MODELS as $section => $cfg) { $candidates = array_values(array_filter($rows, function (array $r) use ($section, $cfg): bool { $depth = substr_count($r['ref'], '.') + 1; return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][1]; })); $aliases = GeneralUtility::makeInstance(MappingRepository::class)->load($cfg['key'])['aliases'] ?? []; $out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table'], $aliases); } return $out; } /** @param array> $rows * @return array{total:int,found:int,missing:array>} */ private function pagesCheck(array $rows): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); $slugs = $qb->select('slug')->from('pages') ->where( $qb->expr()->eq('deleted', 0), $qb->expr()->eq('sys_language_uid', 0) ) ->executeQuery()->fetchFirstColumn(); $existing = array_flip(array_map(static fn($s): string => rtrim((string)$s, '/') ?: '/', $slugs)); $missing = []; $found = 0; foreach ($rows as $r) { $slug = rtrim($r['url'], '/') ?: '/'; if (isset($existing[$slug])) { $found++; } else { $missing[] = $r; } } return ['total' => count($rows), 'found' => $found, 'missing' => $missing]; } /** * @param array> $candidates * @param array $aliases lowercased CSV name -> record uid * @return array{total:int,matched:int,both:array>,missing:array>,extra:array>,linkable:array>} */ private function recordsCheck(array $candidates, string $table, array $aliases): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); $records = $qb->select('uid', 'title', 'slug')->from($table) ->where($qb->expr()->eq('deleted', 0)) ->executeQuery()->fetchAllAssociative(); $bySlug = []; $byTitle = []; $byUid = []; foreach ($records as $rec) { $slug = mb_strtolower(trim((string)($rec['slug'] ?? ''))); if ($slug !== '') { $bySlug[$slug] = $rec; } $byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec; $byUid[(int)$rec['uid']] = $rec; } $missing = []; $both = []; $matchedUids = []; $directUids = []; foreach ($candidates as $r) { $segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/')))); $rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null; $via = ''; if ($rec === null) { $aliasUid = (int)($aliases[mb_strtolower(trim($r['name']))] ?? 0); if ($aliasUid > 0 && isset($byUid[$aliasUid])) { $rec = $byUid[$aliasUid]; $via = 'link'; } } if ($rec !== null) { $matchedUids[(int)$rec['uid']] = true; if ($via === '') { $directUids[(int)$rec['uid']] = true; } $both[] = [ 'ref' => $r['ref'], 'name' => $r['name'], 'url' => $r['url'], 'uid' => (string)$rec['uid'], 'title' => (string)$rec['title'], 'via' => $via, ]; } else { $missing[] = $r; } } $extra = []; $linkable = []; foreach ($records as $rec) { if (!isset($matchedUids[(int)$rec['uid']])) { $extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']]; } // Select options for the hand-made links: everything the direct // match (slug/title) did not claim - alias-linked records stay in // here so a linked row can be re-pointed or unlinked. if (!isset($directUids[(int)$rec['uid']])) { $linkable[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']]; } } usort($linkable, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title'])); return [ 'total' => count($candidates), 'matched' => count($matchedUids), 'both' => $both, 'missing' => $missing, 'extra' => $extra, 'linkable' => $linkable, ]; } private function normalizeTitle(string $title): string { $title = (string)preg_replace('/\s*\(.*?\)/', '', $title); // drop parenthetical suffixes $title = str_replace('&', 'and', mb_strtolower($title)); $title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title); return trim((string)preg_replace('/\s+/', ' ', $title)); } // ------------------------------------------------------------------ diff /** * @param array> $old * @param array> $new * @return array{added:array,removed:array,changed:array} */ private function diff(array $old, array $new): array { $byUrlOld = []; foreach ($old as $r) { $byUrlOld[$r['url']] = $r; } $byUrlNew = []; foreach ($new as $r) { $byUrlNew[$r['url']] = $r; } $added = []; $changed = []; foreach ($byUrlNew as $url => $r) { $o = $byUrlOld[$url] ?? null; if ($o === null) { $added[] = sprintf('%s (%s)', $url, $r['name']); continue; } $changes = []; foreach (['primary' => 'primary keyword', 'secondary' => 'secondary keyword', 'flag' => 'flag'] as $field => $label) { if ($r[$field] !== $o[$field]) { $changes[] = sprintf('%s "%s" -> "%s"', $label, $o[$field], $r[$field]); } } if ($changes !== []) { $changed[] = $url . ': ' . implode('; ', $changes); } } $removed = []; foreach ($byUrlOld as $url => $r) { if (!isset($byUrlNew[$url])) { $removed[] = sprintf('%s (%s)', $url, $r['name']); } } return ['added' => $added, 'removed' => $removed, 'changed' => $changed]; } }