diff --git a/migrations/seo_report_fixes_20261110.php b/migrations/seo_report_fixes_20261110.php
new file mode 100644
index 0000000..a198d31
--- /dev/null
+++ b/migrations/seo_report_fixes_20261110.php
@@ -0,0 +1,414 @@
+ 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('#]*src="data:image/[^"]*"[^>]*>\s*#i', '', $b, -1, $c1) ?? $b; if ($c1) { $log[] = "$c1 Platzhalter-Bilder"; }
+ $b = preg_replace('#
[^<]*getWR360PopupSkin[^<]*
\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$2
', $b, -1, $c5) ?? $b; if ($c5) { $log[] = "$c5 nackte Textabsaetze gekapselt"; } + $b = preg_replace('#]*)>\s*View products?\s*#i', 'View products', $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 = ''; +$stripAttrs = static function (string $html): string { + // keep href/target on links, drop everything else (style, class, ids) + $html = preg_replace_callback('#]*)>#i', static function (array $m): string { + preg_match('/href="([^"]*)"/i', $m[1], $h); + preg_match('/target="([^"]*)"/i', $m[1], $t); + return ''; + }, $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('#\s*]*>(.*?)#is', $raw, $s)) { $cite = trim(strip_tags($s[1])); } + $texts[] = ']*>(.*?)#is', $raw, $q)) { + $cite = ''; + if (preg_match('#
'; + continue; + } + if (stripos($raw, '' . trim(strip_tags($q[1])) . '
' . ($cite !== '' ? '' . $cite . '
' : '') . '