Mit dem selbstreferenzierenden Canonical (07f74e3) zaehlten
/insights/news?page=2 und ?category=... als eigene Seiten mit dem
Titel von Seite 1 (Seobility: 3 doppelte Titel, 2x Duplicate Content).
VitecMetaHandler haengt jetzt ' - Page N' bzw. die Kategorie an Titel
und Description.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
484 lines
21 KiB
PHP
484 lines
21 KiB
PHP
<?php
|
||
|
||
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;
|
||
|
||
/**
|
||
* Completes the page JSON's `seo` block for the headless frontend.
|
||
*
|
||
* 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
|
||
{
|
||
public function process(ServerRequestInterface $request, array $content): array
|
||
{
|
||
$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;
|
||
}
|
||
|
||
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 = '';
|
||
$canonicalPath = '';
|
||
|
||
// --- 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(
|
||
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
|
||
);
|
||
}
|
||
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
|
||
// Same text as a success story: the story is the original.
|
||
$storyUid = (int)($news['vitec_canonical_usecase'] ?? 0);
|
||
if ($storyUid > 0) {
|
||
$story = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $storyUid]);
|
||
$storySlug = trim((string)($story['slug'] ?? ''));
|
||
if ($storySlug !== '') {
|
||
$canonicalPath = '/success-stories/' . $storySlug;
|
||
}
|
||
}
|
||
}
|
||
} 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;
|
||
}
|
||
// "Betfred HQ | VITEC" is too short to say what the page is.
|
||
$storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title));
|
||
if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) {
|
||
$title = $storyCore . ' | Success Story';
|
||
}
|
||
$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']);
|
||
}
|
||
|
||
// --- 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.
|
||
// An SEO title that merely repeats the page title (the import copied
|
||
// title -> seo_title) counts as "not maintained".
|
||
$seoTitle = trim((string)($page['seo_title'] ?? ''));
|
||
$pageTitle = trim((string)($page['title'] ?? ''));
|
||
// "Aligo | VITEC" as seo_title is the import pattern, not editor work.
|
||
$seoCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $seoTitle));
|
||
$seoTitleMaintained = $seoCore !== '' && mb_strtolower($seoCore) !== mb_strtolower($pageTitle);
|
||
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
|
||
&& $pageTitle !== '' && mb_strlen($pageTitle) < 30
|
||
&& !$seoTitleMaintained
|
||
) {
|
||
$parentTitle = $this->parentTitle($pageInfo);
|
||
if ($parentTitle !== '' && mb_stripos($pageTitle, $parentTitle) === false) {
|
||
$title = $pageTitle . ' | ' . $parentTitle;
|
||
}
|
||
}
|
||
|
||
// --- 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'] : [];
|
||
// ~155 characters is what search engines show (Seobility: 527 "zu lang").
|
||
$description = $this->crop($description, 155);
|
||
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
|
||
|
||
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
|
||
$meta[] = ['name' => 'description', 'content' => $description];
|
||
}
|
||
// Editorial descriptions already in the list get the same length cap.
|
||
foreach ($meta as $index => $entry) {
|
||
if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) {
|
||
$meta[$index]['content'] = $this->crop(trim($entry['content']), 155);
|
||
}
|
||
}
|
||
$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']);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- news article whose canonical is a success story --------------------
|
||
if ($canonicalPath !== '') {
|
||
$href = $this->absolute($canonicalPath, $base);
|
||
$links = is_array($content['seo']['link'] ?? null) ? $content['seo']['link'] : [];
|
||
$replaced = false;
|
||
foreach ($links as $index => $link) {
|
||
if (($link['rel'] ?? '') === 'canonical') {
|
||
$links[$index]['href'] = $href;
|
||
$replaced = true;
|
||
}
|
||
}
|
||
if (!$replaced) {
|
||
$links[] = ['href' => $href, 'rel' => 'canonical'];
|
||
}
|
||
$content['seo']['link'] = $links;
|
||
}
|
||
|
||
// --- paginated / filtered list pages: self-referencing canonical --------
|
||
// /insights/news?page=2 pointed at page 1, which marks every further
|
||
// page as a duplicate and weakens the links to its articles (0100-FE).
|
||
if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) {
|
||
$query = $request->getQueryParams();
|
||
$keep = [];
|
||
foreach (['category', 'page'] as $key) {
|
||
$value = $query[$key] ?? null;
|
||
if (!is_scalar($value) || trim((string)$value) === '') {
|
||
continue;
|
||
}
|
||
if ($key === 'page' && (int)$value <= 1) {
|
||
continue;
|
||
}
|
||
$keep[$key] = trim((string)$value);
|
||
}
|
||
if ($keep !== [] && is_array($content['seo']['link'] ?? null)) {
|
||
foreach ($content['seo']['link'] as $index => $link) {
|
||
if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) {
|
||
$content['seo']['link'][$index]['href'] =
|
||
strtok($link['href'], '?') . '?' . http_build_query($keep);
|
||
}
|
||
}
|
||
}
|
||
// Self-canonical pages need their own title/description, otherwise
|
||
// they count as duplicates of page 1 ("VITEC News and Articles" x3).
|
||
if ($keep !== []) {
|
||
$suffix = [];
|
||
if (isset($keep['category'])) {
|
||
$suffix[] = ucfirst(str_replace('-', ' ', $keep['category']));
|
||
}
|
||
if (isset($keep['page'])) {
|
||
$suffix[] = 'Page ' . (int)$keep['page'];
|
||
}
|
||
$suffixText = ' – ' . implode(' – ', $suffix);
|
||
$currentTitle = trim((string)($content['seo']['title'] ?? ''));
|
||
if ($currentTitle !== '') {
|
||
$content['seo']['title'] = preg_match('/^(.*?)(\s*\|\s*VITEC)$/i', $currentTitle, $m) === 1
|
||
? $m[1] . $suffixText . $m[2]
|
||
: $currentTitle . $suffixText;
|
||
}
|
||
foreach ($content['seo']['meta'] as $index => $entry) {
|
||
if (in_array($entry['name'] ?? '', ['description', 'twitter:description'], true)
|
||
|| ($entry['property'] ?? '') === 'og:description'
|
||
) {
|
||
$content['seo']['meta'][$index]['content'] = rtrim((string)$entry['content'], '. ') . $suffixText . '.';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 keys are the depth (0 = root), but the array is krsort()ed
|
||
// (current page first). Walk up from the parent, skipping structural
|
||
// levels (folders, shortcuts, spacers, technical names) until a page
|
||
// with a presentable name turns up; the site root never counts.
|
||
$rootLine = $pageInfo->getRootLine();
|
||
ksort($rootLine);
|
||
$byDepth = array_values($rootLine);
|
||
for ($i = count($byDepth) - 2; $i >= 1; $i--) {
|
||
$candidate = $byDepth[$i];
|
||
if (!is_array($candidate)) {
|
||
continue;
|
||
}
|
||
if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
|
||
continue;
|
||
}
|
||
$name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? ''));
|
||
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
|
||
continue;
|
||
}
|
||
return $name;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 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.,;:") . '...';
|
||
}
|
||
}
|