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

+414
View File
@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
/**
* Fixes from the Seobility crawl of 2026-11-10 (1,094 pages).
*
* 1. Placeholder SEO title "Title for search engines [seo_title]" on /solutions.
* 2. News records whose title/path_segment were saved twice in a row.
* 3. News path_segments that contain slashes (imported from old URL paths).
* 4. Typos reported by the crawler (content tables).
* 5. Second wave of dead old-site links: /resource-center, /services,
* /contact-sales, /aetria-use-cases, /video-wall-solutions, absolute
* datapath.co.uk / datapath-us.com links and tracking-parameter variants.
* 6. "http://www.vitec.com./" (trailing dot) links.
* 7. Redirects: record shells (/product, /news/article, /success-stories/
* story) onto their overviews, plus a few orphaned product URLs.
* 8. Meta description for /datasheets.
* 9. WordPress remnants inside the 17 SEO articles (lazy-load placeholder
* images, leftover script fragment, wp-/data- attributes, bare text).
* 10. News records of type "internal page" pointing at the old /en/news
* pages: their body lives only on those pages (which now redirect onto
* the empty article URLs). The page content is copied into the record,
* images become fal_media, the record becomes a regular article, and
* every old page gets its 301.
*
* Dry run: php migrations/seo_report_fixes_20261110.php
* Write: php migrations/seo_report_fixes_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$redirectsAdded = 0;
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $apply, &$redirectsAdded): void {
$hasRedirect->execute([$source]);
if ((int)$hasRedirect->fetchColumn() > 0) {
return;
}
$redirectsAdded++;
if ($apply) {
$insertRedirect->execute([$source, $target]);
}
};
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$newsBySegment = $pdo->prepare("SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment = ? LIMIT 1");
$targetExists = static function (string $target) use ($pageBySlug, $productBySlug, $newsBySegment): bool {
if (str_starts_with($target, '/product/')) {
$productBySlug->execute([substr($target, 9)]);
return (int)$productBySlug->fetchColumn() > 0;
}
if (str_starts_with($target, '/news/article/')) {
$newsBySegment->execute([substr($target, 14)]);
return (int)$newsBySegment->fetchColumn() > 0;
}
$pageBySlug->execute([$target]);
return (int)$pageBySlug->fetchColumn() > 0;
};
/* ------------------------------------------------------------------ */
echo "\n1. Platzhalter-SEO-Titel/-Beschreibungen\n";
$rows = $pdo->query("SELECT uid, slug, seo_title FROM pages WHERE deleted = 0 AND (seo_title LIKE 'Title for search engines%' OR description LIKE 'Description for search engines%')")->fetchAll();
foreach ($rows as $r) {
echo " #{$r['uid']} {$r['slug']}: seo_title/description geleert\n";
if ($apply) {
$pdo->prepare("UPDATE pages SET seo_title = IF(seo_title LIKE 'Title for search engines%', '', seo_title), description = IF(description LIKE 'Description for search engines%', '', description), tstamp = ? WHERE uid = ?")->execute([$now, $r['uid']]);
}
}
/* ------------------------------------------------------------------ */
echo "\n2. News mit verdoppeltem Titel / path_segment\n";
$halve = static function (string $s): ?string {
$len = mb_strlen($s);
if ($len < 20 || $len % 2 !== 0) { return null; }
$h = mb_substr($s, 0, intdiv($len, 2));
return $h === mb_substr($s, intdiv($len, 2)) ? $h : null;
};
foreach ($pdo->query("SELECT uid, title, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND CHAR_LENGTH(title) > 40") as $r) {
$newTitle = $halve((string)$r['title']);
if ($newTitle === null) { continue; }
$seg = (string)$r['path_segment'];
$newSeg = $seg;
// doubled slug: "a-b-ca-b-c" -> "a-b-c"
$segLen = strlen($seg);
if ($segLen % 2 === 0 && substr($seg, 0, intdiv($segLen, 2)) === substr($seg, intdiv($segLen, 2))) {
$newSeg = substr($seg, 0, intdiv($segLen, 2));
}
echo " #{$r['uid']}: Titel -> [$newTitle]" . ($newSeg !== $seg ? ", Slug -> $newSeg (+Redirect)" : '') . "\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET title = ?, path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$newTitle, $newSeg, $now, $r['uid']]);
}
if ($newSeg !== $seg) {
$addRedirect('/news/article/' . $seg, '/news/article/' . $newSeg);
}
}
/* ------------------------------------------------------------------ */
echo "\n3. News path_segment mit Slash\n";
foreach ($pdo->query("SELECT uid, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment LIKE '%/%'") as $r) {
$old = (string)$r['path_segment'];
$new = basename($old);
$newsBySegment->execute([$new]);
$taken = (int)$newsBySegment->fetchColumn();
if ($taken > 0 && $taken !== (int)$r['uid']) {
$new .= '-' . $r['uid'];
}
echo " #{$r['uid']}: $old -> $new (+Redirect)\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, $r['uid']]);
}
$addRedirect('/news/article/' . $old, '/news/article/' . $new);
}
/* ------------------------------------------------------------------ */
echo "\n4. Tippfehler\n";
$typos = [
'continously' => 'continuously', 'continous' => 'continuous', 'indpendent' => 'independent',
'aircrafts' => 'aircraft', 'sponser' => 'sponsor', 'auxillary' => 'auxiliary',
'processer' => 'processor', 'accomodation' => 'accommodation',
'Accomodation' => 'Accommodation', 'Continous' => 'Continuous',
];
$typoTargets = [
'tt_content' => ['bodytext', 'header'],
'tx_news_domain_model_news' => ['bodytext', 'teaser', 'title'],
'tx_vitec_domain_model_usecase' => ['description', 'teaser'],
];
foreach ($typos as $wrong => $right) {
foreach ($typoTargets as $table => $fields) {
foreach ($fields as $field) {
$n = (int)$pdo->query("SELECT COUNT(*) FROM $table WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'))->fetchColumn();
if ($n === 0) { continue; }
echo " $wrong -> $right: $table.$field ($n)\n";
if ($apply) {
$pdo->exec("UPDATE $table SET $field = REPLACE($field, " . $pdo->quote($wrong) . ", " . $pdo->quote($right) . "), tstamp = $now WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'));
}
}
}
}
/* ------------------------------------------------------------------ */
echo "\n5. Tote Alt-Links (zweite Welle, inkl. datapath.co.uk / utm-Varianten)\n";
$awardSeg = 'aetria-award-wins-set-the-tone-for-datapaths-best-ever-ise-week';
$newsBySegment->execute([$awardSeg]);
$awardTarget = (int)$newsBySegment->fetchColumn() > 0 ? '/news/article/' . $awardSeg : '/insights/news';
$mapping2 = [
'/resource-center/legacy-products' => '/products/capture-and-graphics',
'/resource-center/legacy-products/imagedp4' => '/products/image-graphics-cards',
'/resource-center/legacy-products/datapath-x4' => '/products/video-wall-controllers-and-management',
'/resource-center/legacy-products/datapath-dl8' => '/products/video-wall-controllers-and-management',
'/legacy-products/imagedp4-plus' => '/products/image-graphics-cards',
'/resource-center/get-support' => '/support',
'/services/premium-service-package' => '/support/support-plans',
'/services/training' => '/support/training',
'/contact-sales' => '/contact/contact-form',
'/aetria-use-cases/oil-and-gas-control-room' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/energy-trading-and-dispatch' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/cctv-for-university-campus' => '/markets/education',
'/video-wall-solutions/commercial-video-walls' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/control-rooms' => '/solutions/control-room-command-centre-solutions',
'/video-wall-solutions/projection-mapping' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
'/insights/success-stories' => '/success-stories',
'/datapath-products' => '/products',
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-command-center' => '/product/aetria-command-center',
'/datapath-products/aetria-control-room-solutions/aetria-workstation' => '/product/aetria-workstation',
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
'/datapath-products/video-wall-controllers/vsn-v3' => '/product/vsn-v3',
'/datapath-products/video-wall-controllers/vsn' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsnmicro-600' => '/product/vsnmicro-600',
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-s2' => '/product/visionsc-s2',
'/datapath-products/video-wall-management-software/wallcontrol-10-software' => '/product/wall-control-10',
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
'/datapath-products/components-accessories/rack-mounting-kits' => '/products',
'/datapath-products/sqx-ip-capture/active-sqx' => '/product/activesqx',
'/datapath-products/video-wall-control' => '/products/video-wall-controllers-and-management',
'/' . $awardSeg => $awardTarget,
];
$missing = 0;
foreach (array_unique(array_values($mapping2)) as $t) {
if (!$targetExists($t)) { echo " !! Ziel fehlt: $t\n"; $missing++; }
}
if ($missing > 0 && $apply) { exit("Abbruch: Ziele fehlen.\n"); }
$hostPrefix = '(?:https?://(?:www\.)?datapath(?:-us)?\.(?:co\.uk|com))?';
$linkTables = ['tx_news_domain_model_news' => 'bodytext', 'tt_content' => 'bodytext'];
$linkHits = 0;
foreach ($linkTables as $table => $field) {
$select = $pdo->query("SELECT uid, $field AS body FROM $table WHERE deleted = 0 AND ($field LIKE '%datapath%' OR $field LIKE '%/resource-center/%' OR $field LIKE '%/services/%' OR $field LIKE '%/contact-sales%' OR $field LIKE '%/aetria-use-cases/%' OR $field LIKE '%/video-wall-solutions%' OR $field LIKE '%/insights/success-stories%' OR $field LIKE '%/legacy-products/%' OR $field LIKE '%aetria-award-wins%')");
$update = $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?");
foreach ($select as $r) {
$body = (string)$r['body'];
$new = $body;
foreach ($mapping2 as $old => $target) {
$pattern = '#href="' . $hostPrefix . preg_quote($old, '#') . '/?(?:\?[^"]*)?"#';
$new = preg_replace($pattern, 'href="' . $target . '"', $new) ?? $new;
}
if ($new !== $body) {
$linkHits++;
echo " $table #{$r['uid']}: Links ersetzt\n";
if ($apply) { $update->execute([$new, $now, $r['uid']]); }
}
}
}
echo " Records mit ersetzten Links: $linkHits\n";
foreach ($mapping2 as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n6. http://www.vitec.com./ (Punkt-Tippfehler)\n";
$n = (int)$pdo->query("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'")->fetchColumn();
echo " $n Record(s)\n";
if ($apply && $n > 0) {
$pdo->exec("UPDATE tx_news_domain_model_news SET bodytext = REPLACE(REPLACE(bodytext, 'http://www.vitec.com./', 'https://www.vitec.com/'), 'https://www.vitec.com./', 'https://www.vitec.com/'), tstamp = $now WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'");
}
/* ------------------------------------------------------------------ */
echo "\n7. Redirects fuer Record-Shells und verwaiste Produkt-URLs\n";
foreach ([
'/product' => '/products',
'/news/article' => '/insights/news',
'/success-stories/story' => '/success-stories',
'/product/artiosign' => '/products/video-wall-management-software',
'/product/artioview' => '/products/video-wall-management-software',
'/product/x-view-integrated-displays' => '/products/x-view-integrated-displays',
] as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n8. Meta-Description /datasheets\n";
$st = $pdo->prepare("UPDATE pages SET description = ?, tstamp = ? WHERE uid = 17 AND (description IS NULL OR description = '')");
$desc = 'Download VITEC and Datapath product datasheets: encoders and decoders, IPTV and digital signage platforms, video wall controllers, capture cards and more - searchable by product and category.';
if ($apply) { $st->execute([$desc, $now]); echo " gesetzt (" . $st->rowCount() . ")\n"; } else { echo " wird gesetzt, falls leer\n"; }
/* ------------------------------------------------------------------ */
echo "\n9. WordPress-Reste in den SEO-Artikeln\n";
$seoUids = [232,234,235,236,237,238,240,241,242,243,244,245,246,247,248,249,252];
$sel = $pdo->query("SELECT uid, path_segment, bodytext FROM tx_news_domain_model_news WHERE deleted = 0 AND uid IN (" . implode(',', $seoUids) . ")");
$upd = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, tstamp = ? WHERE uid = ?");
foreach ($sel as $r) {
$b = (string)$r['bodytext'];
$o = $b;
$log = [];
$b = preg_replace('#<img[^>]*src="data:image/[^"]*"[^>]*>\s*#i', '', $b, -1, $c1) ?? $b; if ($c1) { $log[] = "$c1 Platzhalter-Bilder"; }
$b = preg_replace('#<p>[^<]*getWR360PopupSkin[^<]*</p>\s*#i', '', $b, -1, $c2) ?? $b; if ($c2) { $log[] = "Script-Rest"; }
$b = preg_replace('#\s+class="wp-[^"]*"#i', '', $b, -1, $c3) ?? $b; if ($c3) { $log[] = "$c3 wp-Klassen"; }
$b = preg_replace('#\s+data-[a-z0-9-]+="[^"]*"#i', '', $b, -1, $c4) ?? $b; if ($c4) { $log[] = "$c4 data-Attribute"; }
// bare description text between a product <h3> and the following link
$b = preg_replace('#(</h3>)\s*([^<\s][^<]{20,}?)\s*(?=<a |<h[2-4]|<p>|<ul>)#s', '$1<p>$2</p>', $b, -1, $c5) ?? $b; if ($c5) { $log[] = "$c5 nackte Textabsaetze gekapselt"; }
$b = preg_replace('#<a([^>]*)>\s*View products?\s*</a>#i', '<a$1>View products</a>', $b, -1, $c6) ?? $b; if ($c6) { $log[] = "$c6 View-products-Links normalisiert"; }
if ($b !== $o) {
echo " #{$r['uid']} {$r['path_segment']}: " . implode(', ', $log) . "\n";
if ($apply) { $upd->execute([$b, $now, $r['uid']]); }
}
}
/* ------------------------------------------------------------------ */
echo "\n10. News-Zeiger-Records (type=1) -> Inhalt der alten Seite uebernehmen\n";
$allowedTags = '<p><h2><h3><h4><ul><ol><li><strong><em><b><i><a><br><blockquote>';
$stripAttrs = static function (string $html): string {
// keep href/target on links, drop everything else (style, class, ids)
$html = preg_replace_callback('#<a\b([^>]*)>#i', static function (array $m): string {
preg_match('/href="([^"]*)"/i', $m[1], $h);
preg_match('/target="([^"]*)"/i', $m[1], $t);
return '<a' . (isset($h[1]) ? ' href="' . $h[1] . '"' : '') . (isset($t[1]) ? ' target="' . $t[1] . '"' : '') . '>';
}, $html) ?? $html;
return preg_replace('#<(p|h[2-4]|ul|ol|li|strong|em|b|i|blockquote|br)\b[^>]*>#i', '<$1>', $html) ?? $html;
};
$collectContent = null;
$collectContent = static function (int $pid, int $parent, array &$texts, array &$imageRefs) use ($pdo, &$collectContent, $allowedTags, $stripAttrs): void {
$st = $pdo->prepare("SELECT uid, CType, bodytext, header FROM tt_content WHERE pid = ? AND tx_container_parent = ? AND deleted = 0 AND hidden = 0 ORDER BY colPos, sorting");
$st->execute([$pid, $parent]);
foreach ($st->fetchAll() as $el) {
$ctype = (string)$el['CType'];
if (str_starts_with($ctype, 'vitec_container') || str_starts_with($ctype, 'vitec_cols')) {
$collectContent($pid, (int)$el['uid'], $texts, $imageRefs);
continue;
}
if (in_array($ctype, ['image', 'textpic', 'textmedia'], true)) {
$refs = $pdo->prepare("SELECT uid FROM sys_file_reference WHERE tablenames = 'tt_content' AND uid_foreign = ? AND fieldname IN ('image', 'assets', 'media') AND deleted = 0 AND hidden = 0 ORDER BY sorting_foreign");
$refs->execute([(int)$el['uid']]);
foreach ($refs->fetchAll() as $ref) { $imageRefs[] = (int)$ref['uid']; }
}
if (in_array($ctype, ['text', 'textpic', 'textmedia'], true)) {
$t = trim((string)$el['bodytext']);
if ($t !== '') { $texts[] = $stripAttrs(strip_tags($t, $allowedTags)); }
continue;
}
if ($ctype === 'html') {
$raw = (string)$el['bodytext'];
if (preg_match('#<blockquote[^>]*>(.*?)</blockquote>#is', $raw, $q)) {
$cite = '';
if (preg_match('#</blockquote>\s*<span[^>]*>(.*?)</span>#is', $raw, $s)) { $cite = trim(strip_tags($s[1])); }
$texts[] = '<blockquote><p>' . trim(strip_tags($q[1])) . '</p>' . ($cite !== '' ? '<p><em>' . $cite . '</em></p>' : '') . '</blockquote>';
continue;
}
if (stripos($raw, '<style') !== false || stripos($raw, '<script') !== false || mb_strlen(trim(strip_tags($raw))) < 200) {
continue; // layout snippets (buttons, styles)
}
$texts[] = $stripAttrs(strip_tags($raw, $allowedTags));
}
}
};
$pointerRows = $pdo->query("SELECT n.uid, n.pid, n.title, n.path_segment, n.internalurl, n.bodytext, n.fal_media FROM tx_news_domain_model_news n WHERE n.deleted = 0 AND n.type = '1'")->fetchAll();
$pageSlugs = $pdo->prepare("SELECT uid, slug, pid FROM pages WHERE uid = ? AND deleted = 0");
$maxRefSorting = $pdo->prepare("SELECT COALESCE(MAX(sorting_foreign), 0) FROM sys_file_reference WHERE tablenames = 'tx_news_domain_model_news' AND fieldname = 'fal_media' AND uid_foreign = ?");
$copyRef = $pdo->prepare(
"INSERT INTO sys_file_reference (pid, tstamp, crdate, uid_local, uid_foreign, tablenames, fieldname, sorting_foreign, title, alternative, description, crop, link)
SELECT ?, ?, ?, uid_local, ?, 'tx_news_domain_model_news', 'fal_media', ?, title, alternative, description, crop, link FROM sys_file_reference WHERE uid = ?"
);
$updNews = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, type = '0', internalurl = '', fal_media = ?, tstamp = ? WHERE uid = ?");
$updType = $pdo->prepare("UPDATE tx_news_domain_model_news SET type = '0', internalurl = '', tstamp = ? WHERE uid = ?");
$migrated = 0; $typeOnly = 0; $skipped = 0;
foreach ($pointerRows as $n) {
$pid = 0;
if (preg_match('/uid=(\d+)/', (string)$n['internalurl'], $m)) { $pid = (int)$m[1]; } elseif (ctype_digit((string)$n['internalurl'])) { $pid = (int)$n['internalurl']; }
$pageSlugs->execute([$pid]);
$page = $pageSlugs->fetch();
if (!$page || !in_array((int)$page['pid'], [106], true) && (int)$page['uid'] !== 107) {
echo " #{$n['uid']}: Ziel-Seite $pid nicht im alten News-Ast - uebersprungen\n";
$skipped++;
continue;
}
$oldSlug = (string)$page['slug'];
$newUrl = '/news/article/' . $n['path_segment'];
if (trim((string)$n['bodytext']) !== '') {
echo " #{$n['uid']}: hat eigenen Text (" . strlen((string)$n['bodytext']) . " Z.) -> nur type=0" . "\n";
$typeOnly++;
if ($apply) { $updType->execute([$now, $n['uid']]); }
$addRedirect($oldSlug, $newUrl);
continue;
}
$texts = []; $imageRefs = [];
$collectContent($pid, 0, $texts, $imageRefs);
$body = trim(implode("\n", $texts));
$plainLen = mb_strlen(trim(strip_tags($body)));
if ($plainLen < 500) {
echo " #{$n['uid']}: Seite $pid liefert nur $plainLen Zeichen Text - uebersprungen (bleibt Zeiger)\n";
$skipped++;
continue;
}
$imageRefs = array_slice(array_unique($imageRefs), 0, 3);
// Imported placeholder slugs ("default-<hash>") take the old page's slug.
$segment = (string)$n['path_segment'];
if (str_starts_with($segment, 'default-')) {
$candidate = basename($oldSlug);
$newsBySegment->execute([$candidate]);
if ((int)$newsBySegment->fetchColumn() === 0) {
$segment = $candidate;
$newUrl = '/news/article/' . $segment;
echo " #{$n['uid']}: Slug default-... -> $segment\n";
}
}
echo " #{$n['uid']} $segment: $plainLen Zeichen aus " . count($texts) . " Elementen, " . count($imageRefs) . " Bild(er) <- $oldSlug\n";
$migrated++;
if ($apply) {
if ($segment !== (string)$n['path_segment']) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ? WHERE uid = ?")->execute([$segment, $n['uid']]);
}
$sorting = 0;
$maxRefSorting->execute([$n['uid']]);
$sorting = (int)$maxRefSorting->fetchColumn();
$existing = (int)$n['fal_media'];
foreach ($imageRefs as $refUid) {
$sorting += 256;
$copyRef->execute([(int)$n['pid'], $now, $now, (int)$n['uid'], $sorting, $refUid]);
$existing++;
}
$updNews->execute([$body, $existing, $now, $n['uid']]);
}
$addRedirect($oldSlug, $newUrl);
}
echo " uebernommen: $migrated, nur type=0: $typeOnly, uebersprungen: $skipped\n";
echo "\nRedirects neu: $redirectsAdded\n";
echo $apply ? "FERTIG. cache:flush + SSR purge + warm.\n" : "DRY RUN beendet.\n";
@@ -47,6 +47,19 @@ class ProcessedFileReferenceToArray extends FileReferenceToArray
$mimeType = (string)$file->getMimeType(); $mimeType = (string)$file->getMimeType();
$extension = strtolower((string)$file->getExtension()); $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['mimeType'] = $mimeType;
$data['size'] = (int)$file->getSize(); $data['size'] = (int)$file->getSize();
@@ -141,6 +141,20 @@ class VitecMetaHandler extends MetaHandler
$image = $this->firstImage('pages', 'media', (int)$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.
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 --------------------------------------------- // --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) { if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC'; $title .= ' | VITEC';
@@ -209,6 +223,31 @@ class VitecMetaHandler extends MetaHandler
return $content; 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). * Route arguments of the current request (record detail context).
* *
@@ -430,6 +430,25 @@ final class NewsJsonRenderer
$requestedPage = max(1, (int)($queryParams['page'] ?? 1)); $requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? ''))); $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 // All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter) // chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels. // and the per-card category labels.
@@ -504,7 +523,16 @@ final class NewsJsonRenderer
'settings' => $this->buildSettingsPayload($contentElement, $settings), '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; $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( $usecases = array_map(
static function (array $u) use ($serializer, $detailBase): array { static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u); $item = $serializer->serializeListItem($u);
@@ -210,6 +220,80 @@ class UsecaseListJsonRenderer
* Callers here build paths by concatenation, so the empty string stays the * Callers here build paths by concatenation, so the empty string stays the
* "no link" value; LinkResolver's null is coalesced away. * "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 private function resolvePageUrl(int $pageUid): string
{ {
return LinkResolver::pageUrl($pageUid) ?? ''; return LinkResolver::pageUrl($pageUid) ?? '';
+6
View File
@@ -302,6 +302,12 @@ AddDefaultCharset utf-8
RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$ RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$
RewriteRule ^.*$ - [E=CWD:%2] RewriteRule ^.*$ - [E=CWD:%2]
# robots.txt per host: the Sitemap directive must be an absolute URL on the
# crawled host, so dev serves its own file (Seobility found "0 sitemaps"
# because robots.txt pointed at www.vitec.com). SEO audit 2026-11-10.
RewriteCond %{HTTP_HOST} ^dev\.vitec\.com$ [NC]
RewriteRule ^robots\.txt$ robots-dev.txt [L]
# Rules to set ApplicationContext based on hostname # Rules to set ApplicationContext based on hostname
#RewriteCond %{HTTP_HOST} ^dev\.example\.com$ #RewriteCond %{HTTP_HOST} ^dev\.example\.com$
#RewriteRule .? - [E=TYPO3_CONTEXT:Development] #RewriteRule .? - [E=TYPO3_CONTEXT:Development]
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /typo3/
Disallow: /_json/
Sitemap: https://dev.vitec.com/sitemap.xml