Files
VITEC-website/packages/vitec/Classes/Import/SeoResearchService.php
Oliver Rasche 1011162bb1 Backend module: CSV import for the VITEC domain models
New "VITEC Import" module under Web: one import page per domain model
(v1: Market, Solution, Product - registry-driven, adding a model is one
config entry). Workflow: upload a CSV (delimiter and encoding are
auto-detected, including German-Excel semicolon/Windows-1252), map CSV
columns to DB fields, persist the mapping per model together with the
identity field used for matching (new table tx_vitec_import_mapping,
no TCA - pure tool configuration), review a unified list of CSV rows
matched against the DB records (new / update with differing fields /
unchanged / db-only), then apply the checked rows through DataHandler.

Each importable row carries an editable JSON payload textarea - what
is written is the textarea content, not the raw CSV, so editors can
fix values right in the review step. The parsed CSV travels through
the form as a hidden JSON field: no session state, no temp files.
Importable fields are derived from TCA at runtime (scalar types only;
files, categories and other relations are excluded - a flat CSV
cannot carry them). Payloads are whitelisted against that field list
on apply; new records require a storage pid (prefilled from existing
records). BE user permissions apply via DataHandler.

The module ships its own CSS (backend-import.css, loaded only by
this module) using the frontend button palette from _vitec.scss:
orange #f47937 for primary actions, navy #26358c for secondary
actions and structure. The stray <h2>Hi</h2> debug leftover in the
shared backend layout is removed (also affects the OG Image module).

A fourth tab "SEO Research" handles the recurring keyword-research
CSV. It is deliberately not an import mask - the file carries research
only (no meta title/description yet). Each upload is persisted as a
delivery (tx_vitec_seo_research, never deleted) and evaluated: diff
against the previous delivery keyed by URL, a structure check of the
CSV tree against TYPO3 (pages by slug path; market/solution/product
rows against the domain tables, matched by slug then normalized
title), and the three work lists from the SEO flags (quick wins by
GSC impressions, shared terms grouped by keyword, already ranking).
CsvReader now deduplicates repeated header names, which that CSV has.

New CLI command vitec:create-markets: creates the market records the
structure check reports as missing, sourced from the stored delivery
and matched through the same SeoResearchService - what the module
lists is what the command creates. Each market gets a sys_category of
the same title, found anywhere under the auto-detected market category
root or created; sub-market categories are created under the parent
market's category, so the category tree carries the hierarchy the
flat market model cannot. Idempotent, dry-run first.

New CLI commands vitec:create-markets and vitec:market-dummy-image.
create-markets creates the market records the structure check reports
as missing (root detection three-staged: option, auto-detect, find or
create a "Markets" category). market-dummy-image assigns a shared
placeholder (white logo on brand navy, fileadmin/placeholders/) to
every market without an image - one sys_file for all, replacing the
file restyles every placeholder at once. Both idempotent.

Deliberately out of v1: import log with three-way compare (protection
against overwriting manual edits), images/relations, multiple saved
mappings per model.
2026-08-10 14:56:19 +02:00

286 lines
11 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).
* - 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];
}));
$out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table']);
}
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
* @return array{total:int,matched:int,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>}
*/
private function recordsCheck(array $candidates, string $table): 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 = [];
foreach ($records as $rec) {
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
if ($slug !== '') {
$bySlug[$slug] = $rec;
}
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
}
$missing = [];
$both = [];
$matchedUids = [];
foreach ($candidates as $r) {
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
$rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null;
if ($rec !== null) {
$matchedUids[(int)$rec['uid']] = true;
$both[] = [
'ref' => $r['ref'],
'name' => $r['name'],
'url' => $r['url'],
'uid' => (string)$rec['uid'],
'title' => (string)$rec['title'],
];
} else {
$missing[] = $r;
}
}
$extra = [];
foreach ($records as $rec) {
if (!isset($matchedUids[(int)$rec['uid']])) {
$extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
}
}
return [
'total' => count($candidates),
'matched' => count($matchedUids),
'both' => $both,
'missing' => $missing,
'extra' => $extra,
];
}
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];
}
}