Seobility-Report 2026-11-10: Backend-Befunde umgesetzt

Crawl nach dem Footer-Fix: 1.094 Seiten (vorher 9). Backend-Anteil:

- Migration seo_report_fixes_20261110: Platzhalter-SEO-Titel auf /solutions
  geleert; News mit verdoppeltem Titel/Slug und Slugs mit Slash bereinigt
  (+Redirects); 8 Tippfehler; zweite Welle toter Alt-Links (datapath.co.uk-
  Absolutlinks, utm-Varianten, /resource-center, /services, /contact-sales,
  /aetria-use-cases, /video-wall-solutions ...) in 48 News ersetzt + 101
  Redirects; Record-Shells /product, /news/article, /success-stories/story
  leiten auf ihre Uebersichten; Meta-Description /datasheets; WordPress-
  Reste in den 17 SEO-Artikeln entfernt; 53 News-Zeiger-Records (type=1
  auf alte /en/news-Seiten, ohne eigenen Text) bekommen den Seiteninhalt
  (Text + Bilder) in den Record und werden normale Artikel - die alten
  Seiten leiten per 301 auf die Artikel.
- VitecMetaHandler: kurze Seitentitel ohne SEO-Titel erhalten ihren
  Bereich als Kontext ('Aligo | AV over IP & KVM | VITEC').
- ProcessedFileReferenceToArray: Alt-Text-Fallback (Referenz-Titel, dann
  Dateititel) fuer Content-Block-Bilder.
- UsecaseListJsonRenderer: Testimonial-Baender liefern nur noch Stories
  mit Zitat, aktueller Markt zuerst, max. 12 (vorher alle 70 = 226 kB auf
  jeder Markt-/Solution-Unterseite, identische Bloecke auf ~90 Seiten).
- NewsJsonRenderer: paginierte Listen-Payloads 15 Min. im hash-Cache
  (USER_INT rendert sonst jede Variante neu; 50 langsame URLs im Report).
- robots.txt host-abhaengig: dev bekommt robots-dev.txt mit der dev-Sitemap
  (Seobility fand '0 Sitemaps', weil die Sitemap-Zeile auf www zeigte).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5.1 committed 2026-11-10 17:47:55 +01:00
1 parent 5121eaa000
commit 8a652063c5
7 files changed
+590 -1

No files matched your search

@@ -47,6 +47,19 @@ class ProcessedFileReferenceToArray extends FileReferenceToArray
$mimeType = (string)$file->getMimeType();
$extension = strtolower((string)$file->getExtension());
// Alt-text fallback (Seobility: 215 pages with empty alt): the
// reference title, then the file's title property. Editors can
// still override per reference.
if (trim((string)($data['alt'] ?? '')) === '') {
$fallbackAlt = trim((string)($file->getTitle() ?? ''));
if ($fallbackAlt === '') {
$fallbackAlt = trim((string)($file->getOriginalFile()->getProperty('title') ?? ''));
}
if ($fallbackAlt !== '') {
$data['alt'] = $fallbackAlt;
}
}
$data['mimeType'] = $mimeType;
$data['size'] = (int)$file->getSize();
@@ -141,6 +141,20 @@ class VitecMetaHandler extends MetaHandler
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
}
// --- short page titles get their section as context -------------------
// "Aligo | VITEC" (Seobility: 456 titles "zu kurz") becomes
// "Aligo | AV over IP & KVM | VITEC"; only for plain pages without an
// editor-set SEO title, never for record detail pages.
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
&& $title !== '' && mb_strlen($title) < 30
&& trim((string)($page['seo_title'] ?? '')) === ''
) {
$parentTitle = $this->parentTitle($pageInfo);
if ($parentTitle !== '' && mb_stripos($title, $parentTitle) === false) {
$title .= ' | ' . $parentTitle;
}
}
// --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC';
@@ -209,6 +223,31 @@ class VitecMetaHandler extends MetaHandler
return $content;
}
/**
* Navigation title of the parent page, or '' when the parent is the site
* root, a technical container (user_nav, folders) or missing.
*/
private function parentTitle($pageInfo): string
{
if ($pageInfo === null) {
return '';
}
$rootLine = $pageInfo->getRootLine();
$parent = $rootLine[1] ?? null;
if (!is_array($parent) || count($rootLine) < 3) {
// parent is the root page (rootline: current, root) or missing
return '';
}
if (in_array((int)($parent['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
return '';
}
$name = trim((string)($parent['nav_title'] ?? '')) ?: trim((string)($parent['title'] ?? ''));
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
return '';
}
return $name;
}
/**
* Route arguments of the current request (record detail context).
*
@@ -430,6 +430,25 @@ final class NewsJsonRenderer
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
// USER_INT renders on every request; the 12-card page payload (image
// processing included) took 1-6 s on cold SSR misses (Seobility: 50
// slow URLs, all ?page=N). Keep each page/category variant for 15 min
// in the hash cache - cache:flush clears it, news edits are rare.
$cache = null;
$cacheId = '';
try {
$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('hash');
$cacheId = 'vitec-newslist-' . (int)($contentElement['uid'] ?? 0)
. '-' . (int)($contentElement['sys_language_uid'] ?? 0)
. '-' . $requestedPage . '-' . md5($requestedCategory);
$cached = $cache->get($cacheId);
if (is_string($cached) && $cached !== '') {
return $cached;
}
} catch (\Throwable $e) {
$cache = null;
}
// All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels.
@@ -504,7 +523,16 @@ final class NewsJsonRenderer
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
return json_encode($response) ?: '';
$json = json_encode($response) ?: '';
if ($cache !== null && $json !== '') {
try {
$cache->set($cacheId, $json, ['tx_news'], 900);
} catch (\Throwable $e) {
// caching is best effort
}
}
return $json;
}
/**
@@ -165,6 +165,16 @@ class UsecaseListJsonRenderer
$rows = $ordered;
}
// Testimonial bands on sub-market/solution pages shipped ALL 70
// stories (226 kB per page, the same quotes on ~90 pages - Seobility
// 2026-11-10: 34 pages > 500 kB, 1,846 repeated text blocks). Only
// stories with a maintained quote are testimonials; the current
// market's stories come first, capped at twelve. Editor-picked
// lists stay untouched.
if ($layout === 'testimonials' && $selectedUids === []) {
$rows = $this->testimonialRows($rows);
}
$usecases = array_map(
static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u);
@@ -210,6 +220,80 @@ class UsecaseListJsonRenderer
* Callers here build paths by concatenation, so the empty string stays the
* "no link" value; LinkResolver's null is coalesced away.
*/
/**
* Stories for a testimonial band: quoted ones only, current market first,
* at most twelve. Falls back to the first twelve rows when no story has
* a quote yet.
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function testimonialRows(array $rows): array
{
$quoted = array_values(array_filter(
$rows,
static fn (array $r): bool => trim((string)($r['quote'] ?? '')) !== ''
));
if ($quoted === []) {
return array_slice($rows, 0, 12);
}
try {
$marketUids = $this->currentMarketUids();
if ($marketUids !== []) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_usecase_market_mm');
$inMarket = array_map('intval', $qb
->select('uid_local')
->from('tx_vitec_usecase_market_mm')
->where($qb->expr()->in('uid_foreign', $qb->createNamedParameter($marketUids, \Doctrine\DBAL\ArrayParameterType::INTEGER)))
->executeQuery()
->fetchFirstColumn());
$matching = array_values(array_filter($quoted, static fn (array $r): bool => in_array((int)$r['uid'], $inMarket, true)));
if (count($matching) >= 3) {
$others = array_values(array_filter($quoted, static fn (array $r): bool => !in_array((int)$r['uid'], $inMarket, true)));
$quoted = array_merge($matching, $others);
}
}
} catch (\Throwable $e) {
// keep the quoted stories in default order
}
return array_slice($quoted, 0, 12);
}
/**
* Market records whose detail page lies on the current rootline.
*
* @return list<int>
*/
private function currentMarketUids(): array
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$pageInfo = $request?->getAttribute('frontend.page.information');
if ($pageInfo === null) {
return [];
}
$pageUids = array_values(array_filter(array_map(
static fn (array $p): int => (int)($p['uid'] ?? 0),
$pageInfo->getRootLine()
)));
if ($pageUids === []) {
return [];
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_market');
return array_map('intval', $qb
->select('uid')
->from('tx_vitec_domain_model_market')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->in('detail_page', $qb->createNamedParameter($pageUids, \Doctrine\DBAL\ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn());
}
private function resolvePageUrl(int $pageUid): string
{
return LinkResolver::pageUrl($pageUid) ?? '';