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

@@ -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 [];
}
}
}