SEO-Audit 2026-11-09: Meta-Komposition, Sitemap, Redirects, robots.txt

- VitecMetaHandler: Record-Metadaten fuer News-/Produkt-/Story-Details
  (Titel, Description, og:/twitter:-Tags, og:image), '| VITEC'-Suffix,
  Fallback-Description aus Seitenfeldern, twitter:card large_image,
  Story-Canonical auf die oeffentliche Kurz-URL
- PageJsonLdRenderer: Product- und NewsArticle-Nodes auf Detailseiten
  (News-Details hatten <title>article</title> und kein Schema)
- NewsJsonRenderer: Listen ohne Volltext-Body (JSON 2.6 MB -> ~0.6 MB)
- XML-Sitemap: News-Teil konfiguriert (Ordner 11+442, Detailseite 21),
  Record-Sitemaps fuer Produkte und Success Stories (kurze URLs),
  Route-Enhancer fuer alle Teile, Shell-/Altseiten ausgeschlossen
- Migration seo_fixes_20261109: 161 Self-Canonicals geleert (warfen u.a.
  Home, Markets, Solutions, Support aus der Sitemap), noindex fuer
  technische und alte News-Seiten, 71 Redirects Alt-URLs -> neue Ziele,
  51 tote Footer-Links ersetzt
- public/robots.txt mit Sitemap-Verweis

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-09 22:11:01 +01:00
1 parent be519f9bc1
commit b9736b9ac7
11 files changed
+814 -25

No files matched your search

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider;
/**
* Success-story sitemap entries with their PUBLIC URLs.
*
* The route enhancer generates /success-stories/story/<slug>, but the
* public canonical URL is /success-stories/<slug> (SuccessStoryPathRewrite
* middleware, old-site SEO URLs). The sitemap must list the canonical
* form, so the generated loc is rewritten accordingly.
*/
final class StoriesXmlSitemapDataProvider extends RecordsXmlSitemapDataProvider
{
protected function defineUrl(array $data): array
{
$data = parent::defineUrl($data);
if (isset($data['loc']) && is_string($data['loc'])) {
$data['loc'] = str_replace('/success-stories/story/', '/success-stories/', $data['loc']);
}
return $data;
}
}
+311 -9
View File
@@ -4,20 +4,34 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use Doctrine\DBAL\ParameterType;
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Mirrors the real page <title> into `meta.title` of the page JSON.
* Completes the page JSON's `seo` block for the headless frontend.
*
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page
* content, so on product/usecase detail pages it still shows the generic
* page title ("Product") - the record's seotitle only reaches the
* PageTitleProvider while the content renders. Headless fixes `seo.title`
* afterwards through this handler; we extend it so `meta.title` gets the
* same final value. Registered via the MetaHandlerInterface alias in
* Services.yaml, which covers both the cacheable listener and the
* USER_INT middleware path.
* On top of the stock headless MetaHandler this adds (SEO audit 2026-11-09):
*
* 1. Record-aware metadata: news, product and success-story detail pages
* take title, meta description and og:image from the routed record -
* the shell page itself carries none of that (news details rendered
* as <title>article</title> before this).
* 2. A uniform "| VITEC" title suffix wherever the brand is missing.
* 3. Fallback meta description from the page record (description/abstract).
* 4. og:title / og:description / og:image plus the twitter:* counterparts
* whenever they are missing; twitter:card upgrades to
* summary_large_image once an image exists.
* 5. Canonical rewrite for success stories: the public URL is
* /success-stories/<slug>, not the internal /success-stories/story/<slug>
* (see SuccessStoryPathRewrite middleware).
* 6. Mirrors the final page <title> into `meta.title` (pre-existing: the
* TypoScript lib.meta renders before the record providers run).
*
* Exception-safe: any failure keeps the stock result untouched.
*/
class VitecMetaHandler extends MetaHandler
{
@@ -25,6 +39,12 @@ class VitecMetaHandler extends MetaHandler
{
$content = parent::process($request, $content);
try {
$content = $this->enrich($request, $content);
} catch (\Throwable $e) {
// keep stock output on any failure
}
$title = trim((string)($content['seo']['title'] ?? ''));
if ($title !== '' && is_array($content['meta'] ?? null)) {
$content['meta']['title'] = $title;
@@ -32,4 +52,286 @@ class VitecMetaHandler extends MetaHandler
return $content;
}
/**
* @param array<string,mixed> $content
* @return array<string,mixed>
*/
private function enrich(ServerRequestInterface $request, array $content): array
{
$base = $this->resolveBase($request);
$args = $this->routeArguments($request);
$pageInfo = $request->getAttribute('frontend.page.information');
$page = $pageInfo !== null ? $pageInfo->getPageRecord() : [];
$title = trim((string)($content['seo']['title'] ?? ''));
$description = '';
$image = '';
// --- record context -------------------------------------------------
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($newsUid > 0) {
$news = $this->fetchRow('tx_news_domain_model_news', ['uid' => $newsUid]);
if ($news !== null) {
$recordTitle = trim((string)($news['alternative_title'] ?? ''));
if ($recordTitle === '') {
$recordTitle = trim((string)($news['title'] ?? ''));
}
if ($recordTitle !== '') {
$title = $recordTitle;
}
$description = $this->plainText((string)($news['teaser'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($news['bodytext'] ?? ''));
}
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
}
} elseif ($productArg !== '') {
$product = is_numeric($productArg)
? $this->fetchRow('tx_vitec_domain_model_product', ['uid' => (int)$productArg])
: $this->fetchRow('tx_vitec_domain_model_product', ['slug' => $productArg]);
if ($product !== null) {
$description = trim((string)($product['seometa'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($product['teaser'] ?? ''));
}
$productUid = (int)$product['uid'];
$image = $this->firstImage('tx_vitec_domain_model_product', 'ogimage', $productUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_product', 'productimage', $productUid);
}
}
} elseif ($usecaseUid > 0) {
$usecase = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $usecaseUid]);
if ($usecase !== null) {
$recordTitle = trim((string)($usecase['seo_title'] ?? ''));
if ($recordTitle !== '') {
$title = $recordTitle;
}
$description = trim((string)($usecase['seo_description'] ?? ''));
if ($description === '') {
$description = trim((string)($usecase['og_description'] ?? ''));
}
if ($description === '') {
$description = $this->plainText((string)($usecase['teaser'] ?? ''));
}
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'og_image', $usecaseUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'card_image', $usecaseUid);
}
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'hero_bgimage', $usecaseUid);
}
}
}
// --- fallbacks from the page record ----------------------------------
if ($description === '') {
$description = trim((string)($page['description'] ?? ''));
if ($description === '') {
$description = trim((string)($page['abstract'] ?? ''));
}
}
if ($image === '' && !empty($page['uid'])) {
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
}
// --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC';
}
if ($title !== '') {
$content['seo']['title'] = $title;
}
// --- compose meta list ------------------------------------------------
$meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : [];
$description = $this->crop($description, 300);
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
$meta[] = ['name' => 'description', 'content' => $description];
}
$finalDescription = $this->metaContent($meta, 'name', 'description');
if ($finalDescription === '') {
$finalDescription = $description;
}
if ($title !== '' && !$this->hasMeta($meta, 'property', 'og:title')) {
$meta[] = ['property' => 'og:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'property', 'og:description')) {
$meta[] = ['property' => 'og:description', 'content' => $finalDescription];
}
if ($absImage !== '' && !$this->hasMeta($meta, 'property', 'og:image')) {
$meta[] = ['property' => 'og:image', 'content' => $absImage];
}
if ($title !== '' && !$this->hasMeta($meta, 'name', 'twitter:title')) {
$meta[] = ['name' => 'twitter:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'name', 'twitter:description')) {
$meta[] = ['name' => 'twitter:description', 'content' => $finalDescription];
}
$finalImage = $this->metaContent($meta, 'property', 'og:image');
if ($finalImage !== '' && !$this->hasMeta($meta, 'name', 'twitter:image')) {
$meta[] = ['name' => 'twitter:image', 'content' => $finalImage];
}
if ($finalImage !== '') {
$hasCard = false;
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'twitter:card') {
$meta[$index]['content'] = 'summary_large_image';
$hasCard = true;
}
}
if (!$hasCard) {
$meta[] = ['name' => 'twitter:card', 'content' => 'summary_large_image'];
}
}
$content['seo']['meta'] = $meta;
// --- success-story canonical: public short URL ------------------------
if (is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (isset($link['href']) && is_string($link['href'])) {
$content['seo']['link'][$index]['href'] =
str_replace('/success-stories/story/', '/success-stories/', $link['href']);
}
}
}
return $content;
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments(ServerRequestInterface $request): array
{
$routing = $request->getAttribute('routing');
$args = $routing instanceof PageArguments ? $routing->getArguments() : [];
// Query-parameter fallback (uncached plugin context).
return array_replace_recursive($request->getQueryParams(), $args);
}
/**
* @param array<string,int|string> $where equality constraints
* @return array<string,mixed>|null
*/
private function fetchRow(string $table, array $where): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$constraints = [
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
];
foreach ($where as $field => $value) {
$constraints[] = is_int($value)
? $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter((string)$value, ParameterType::STRING));
}
$row = $qb->select('*')->from($table)->where(...$constraints)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
}
/**
* Root-relative URL of the first FAL image attached to a record field.
*/
private function firstImage(string $table, string $field, int $uid): string
{
if ($uid <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
return $identifier !== '' ? '/fileadmin' . $identifier : '';
}
/**
* @param list<array<string,string>> $meta
*/
private function hasMeta(array $meta, string $attribute, string $key): bool
{
return $this->metaContent($meta, $attribute, $key) !== '';
}
/**
* @param list<array<string,string>> $meta
*/
private function metaContent(array $meta, string $attribute, string $key): string
{
foreach ($meta as $entry) {
if (strtolower((string)($entry[$attribute] ?? '')) === $key) {
return trim((string)($entry['content'] ?? ''));
}
}
return '';
}
private function resolveBase(ServerRequestInterface $request): string
{
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
return $url;
}
return $base . '/' . ltrim($url, '/');
}
/**
* Collapse RTE HTML to a single plain-text line.
*/
private function plainText(string $value): string
{
$value = strip_tags($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* Crop to $max characters at a word boundary (ellipsis appended).
*/
private function crop(string $value, int $max): string
{
if ($value === '' || mb_strlen($value) <= $max) {
return $value;
}
$cut = mb_substr($value, 0, $max);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > (int)($max * 0.6)) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " \t.,;:") . '...';
}
}
@@ -156,7 +156,7 @@ final class NewsJsonRenderer
}
$newsRows = $this->fetchNewsList($contentElement, $settings);
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows);
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
$response = [
'mode' => 'list',
@@ -323,7 +323,7 @@ final class NewsJsonRenderer
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function serializeNews(array $news, array $settings = []): array
private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array
{
$uid = (int)($news['uid'] ?? 0);
$pathSegment = (string)($news['path_segment'] ?? '');
@@ -338,7 +338,14 @@ final class NewsJsonRenderer
'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''),
'bodytext' => RteResolver::html($news['bodytext'] ?? ''),
// List payloads do not carry the full article body: with ~600
// records that inflated /_json/insights/news to 2.6 MB (and the
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
// card goes blank. The detail mode is unchanged.
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: RteResolver::html($news['bodytext'] ?? ''),
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
@@ -353,6 +360,23 @@ final class NewsJsonRenderer
];
}
/**
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
*/
private function listExcerpt(string $html): string
{
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
if (mb_strlen($text) <= 300) {
return $text;
}
$cut = mb_substr($text, 0, 300);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > 180) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " .,;:") . '...';
}
/**
* Where a news item actually points.
*
@@ -112,6 +112,53 @@ final class PageJsonLdRenderer
// Product on product detail pages (routed record, SEO audit 2026-11-09).
$args = $this->routeArguments($request);
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($productArg !== '') {
$productRow = $this->fetchDetailRecord(
'tx_vitec_domain_model_product',
is_numeric($productArg) ? 'uid' : 'slug',
$productArg
);
if ($productRow !== null) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'productimage', (int)$productRow['uid'], $base, $service);
if ($productImages === []) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'ogimage', (int)$productRow['uid'], $base, $service);
}
$nodes[] = $service->buildProduct([
'uid' => (int)$productRow['uid'],
'title' => (string)($productRow['title'] ?? ''),
'teaser' => (string)($productRow['teaser'] ?? ''),
'description' => (string)($productRow['description'] ?? ''),
'slug' => (string)($productRow['slug'] ?? ''),
], $base, $productImages, $hasOrganization);
}
}
// NewsArticle on EXT:news detail pages (the shell page carries no record data).
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
if ($newsUid > 0) {
$newsRow = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
if ($newsRow !== null) {
$newsTitle = trim((string)($newsRow['alternative_title'] ?? ''));
if ($newsTitle === '') {
$newsTitle = (string)($newsRow['title'] ?? '');
}
$nodes[] = $service->buildNewsArticle([
'seo_title' => '',
'title' => $newsTitle,
'description' => trim(strip_tags((string)($newsRow['teaser'] ?? ''))),
'abstract' => '',
'crdate' => (int)($newsRow['datetime'] ?? 0),
'SYS_LASTCHANGED' => (int)($newsRow['tstamp'] ?? 0),
'slug' => '/news/article/' . (string)($newsRow['path_segment'] ?? ''),
'author' => (string)($newsRow['author'] ?? ''),
'author_email' => (string)($newsRow['author_email'] ?? ''),
], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization);
}
}
return $service->encodeGraph($nodes);
} catch (\Throwable $e) {
return '';
@@ -410,4 +457,77 @@ final class PageJsonLdRenderer
}
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments($request): array
{
$routing = $request->getAttribute('routing');
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
return array_replace_recursive($request->getQueryParams(), $routing->getArguments());
}
return $request->getQueryParams();
}
/**
* One visible record by uid or slug. Null when missing.
*
* @return array<string,mixed>|null
*/
private function fetchDetailRecord(string $table, string $field, string $value): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$row = $qb->select('*')->from($table)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$field === 'uid'
? $qb->expr()->eq('uid', $qb->createNamedParameter((int)$value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Absolute URLs of a record's FAL images (unprocessed originals).
*
* @return string[]
*/
private function recordImageUrls(string $table, string $field, int $uid, string $base, StructuredDataService $service): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$rows = $qb->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->executeQuery()->fetchAllAssociative();
$urls = [];
foreach ($rows as $row) {
$identifier = (string)($row['identifier'] ?? '');
if ($identifier !== '') {
$urls[] = $service->absUrl('/fileadmin' . $identifier, $base);
}
}
return $urls;
} catch (\Throwable $e) {
return [];
}
}
}