Kev's keyword CSV and TYPO3 hold the same solutions under slightly
different names, so the structure check filed them as missing/extra.
Matching is now slug -> normalized title -> record alias, using the
per-model alias store the import tabs got on 2026-08-18
(tx_vitec_import_mapping.record_aliases, key = lowercased CSV name).
One link therefore serves the import union list, the structure check
and vitec:create-markets (same service - linked rows are no longer
proposed as new records).
- every "Missing in TYPO3" row carries a select of all records the
direct match did not claim; "Save record links" persists per model
- alias-matched rows show under "Both" with a "linked" badge and the
same select; option 0 removes the link
- SeoResearchService::recordsCheck() additionally returns `linkable`
(the select options) and flags alias matches with via=link
- new backend POST route seo_aliases
Success stories: --markets-only repair mode
- /success-stories emitted empty `markets` for all 48 stories - a data
problem (code and TCA untouched since 17.08.); read-only diagnosis
script migrations/check_usecase_markets.php added
- vitec:import-success-stories --markets-only re-derives each story's
industries from the export exactly like the full import and rewrites
ONLY the markets MM relation of the existing record via DataHandler;
nothing else is touched, no usecase created or deleted, unresolved
stories keep their current relations
- decision: industries are translated onto the nine main markets of
the current taxonomy (INDUSTRY_TO_MARKET) instead of recreating the
seven deleted industry markets; the repair mode never creates
market records (root cause: those industry markets were deleted
after the 07.08. taxonomy import, orphaning all story relations)
- ensureMarkets() gained a $create flag for reuse by the full import
Story detail pages: real page title
- every story page answered with the generic page title "Story"; same
defect and same fix as the product pages on 21.08.: new
UsecasePageTitleProvider (singleton), registered as vitecUsecase in
config.pageTitleProviders, fed from UsecaseShowJsonRenderer with
seo_title falling back to title
Requires on the server:
vendor/bin/typo3 cache:flush
315 lines
13 KiB
PHP
315 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Import;
|
|
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Evaluates one keyword-research delivery:
|
|
*
|
|
* - work lists from the SEO flags (quick wins, shared terms, already ranking)
|
|
* - structure check: does the CSV tree exist in TYPO3?
|
|
* pages -> 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<int,array<string,string>> $raw
|
|
* @return array<int,array<string,string>>
|
|
*/
|
|
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<int,array<string,string>> $rows
|
|
* @param array<int,array<string,string>>|null $previousRows
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<int,array<string,string>> $rows
|
|
* @return array<int,array<string,string>> */
|
|
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<int,array<string,string>> $rows
|
|
* @return array<int,array{keyword:string,pages:array<int,array<string,string>>}>
|
|
*/
|
|
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<int,array<string,string>> $rows
|
|
* @return array<string,mixed> */
|
|
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<int,array<string,string>> $rows
|
|
* @return array{total:int,found:int,missing:array<int,array<string,string>>} */
|
|
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<int,array<string,string>> $candidates
|
|
* @param array<string,int> $aliases lowercased CSV name -> record uid
|
|
* @return array{total:int,matched:int,both:array<int,array<string,string>>,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>,linkable:array<int,array<string,string>>}
|
|
*/
|
|
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<int,array<string,string>> $old
|
|
* @param array<int,array<string,string>> $new
|
|
* @return array{added:array<int,string>,removed:array<int,string>,changed:array<int,string>}
|
|
*/
|
|
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];
|
|
}
|
|
}
|