diff --git a/migrations/fix_datapath_links_20261110.php b/migrations/fix_datapath_links_20261110.php new file mode 100644 index 0000000..5d1ee0e --- /dev/null +++ b/migrations/fix_datapath_links_20261110.php @@ -0,0 +1,188 @@ +), product category page + * (/products/), market or solution page. + * 2. Queues a 301 sys_redirect for every old path (with and without + * trailing slash) so external backlinks land correctly once the + * datapath.com domains point at this site. + * + * Dry run: php migrations/fix_datapath_links_20261110.php + * Write: php migrations/fix_datapath_links_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"; + +/* Old path (no trailing slash) => new target path. */ +$mapping = [ + // Aetria + '/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-network-manager' => '/product/aetria-network-manager', + '/datapath-products/aetria-control-room-solutions/aetria-touch' => '/product/aetria-touch', + '/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol', + '/datapath-products/aetria-control-room-solutions/operator-workstations' => '/products/operator-workstations', + // AVoIP / KVM + '/datapath-products/arqa-avoip-with-kvm' => '/product/arqa', + '/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm', + '/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo', + '/datapath-products/video-distribution-kvm/aligo-qtx100-quad-transmitter' => '/product/qtx100', + '/datapath-products/video-distribution-kvm/aligo-rx100-receiver' => '/product/rx100', + '/datapath-products/video-distribution-kvm/aligo-tx100-transmitter' => '/product/tx100', + // Capture & graphics + '/datapath-products/graphics-products' => '/products/capture-and-graphics', + '/datapath-products/graphics-products/image2k' => '/product/image-2k', + '/datapath-products/graphics-products/image4k' => '/product/image-4k', + '/datapath-products/sqx-ip-capture' => '/products/activesqx', + '/datapath-products/vision-range' => '/products/vision-video-capture-cards', + '/datapath-products/video-capture-cards/capture-card-features' => '/products/vision-video-capture-cards', + '/datapath-products/video-capture-cards/vision-range/visionrgb-e1s' => '/product/visionrgb-e1s', + '/datapath-products/video-capture-cards/vision-range/visionsd41s' => '/product/visionsd4-1s', + '/datapath-products/video-capture-cards/vision-range/visionsdi2' => '/products/vision-video-capture-cards', + '/datapath-products/video-capture-cards/visionav-range' => '/product/visionav', + '/datapath-products/video-capture-cards/visionav-range/visionhd4' => '/product/visionhd4', + '/datapath-products/video-capture-cards/visionsc-range' => '/products/vision-video-capture-cards', + '/datapath-products/video-capture-cards/visionsc-range/visionsc-dp2' => '/product/visionsc-dp2', + '/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4', + '/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2', + // Multi-display / Fx4 / Hx4 + '/datapath-products/multi-display-controllers/datapath-fx4' => '/product/fx4', + '/datapath-products/multi-display-controllers/datapath-fx4-sdi' => '/product/fx4-sdi', + '/datapath-products/multi-display-controllers/datapath-hx4' => '/product/hx4', + '/datapath-products/multi-display-controllers/multi-display-overview' => '/products/x-series-multi-display-processors', + '/datapath-products/multi-display-controllers/video-wall-designer-software' => '/products/video-wall-management-software', + // Video walls / VSN / WallControl + '/datapath-products/video-wall-control/industrial-wall-controllers' => '/products/vsn-series-video-wall-controllers', + '/datapath-products/video-wall-control/video-wall-controllers' => '/products/vsn-series-video-wall-controllers', + '/datapath-products/video-wall-control/vsn-11-series' => '/products/vsn-series-video-wall-controllers', + '/datapath-products/video-wall-control/vsn-9-series' => '/products/vsn-series-video-wall-controllers', + '/datapath-products/video-wall-controllers' => '/products/video-wall-controllers-and-management', + '/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4', + '/datapath-products/video-wall-controllers/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10', + '/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers', + '/datapath-products/video-wall-management-software' => '/products/video-wall-management-software', + '/datapath-products/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10', + // Accessories (no equivalent records - portfolio page) + '/datapath-products/components-accessories/components-accessories-overview/displayport-accessories' => '/products', + '/datapath-products/components-accessories/pcie-backplanes/express11-g3' => '/products', + // Old market / solution names + '/markets/airport-and-aviation' => '/markets/transport/airports', + '/markets/corporate' => '/markets/corporation', + '/markets/emergency-operation-center' => '/markets/public-safety/emergency-operation-centres', + '/markets/events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences', + '/markets/military-control-room' => '/markets/defense-and-security', + '/markets/network-operation-center' => '/solutions/control-room-command-centre-solutions/control-room-platforms-soc-noc-command-centres', + '/markets/retail-and-hospitality' => '/markets/hospitality', + '/markets/security-and-surveillance' => '/solutions/security-surveillance-solutions', + '/markets/traffic-management-control-centers' => '/markets/government/traffic-and-smart-mobility', + '/markets/utilities' => '/markets/critical-national-infrastructure/energy-and-utilities', + '/video-wall-solutions' => '/solutions/video-wall-data-visualisation', + '/video-wall-solutions/retail-events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences', + '/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions', +]; + +/* ------------------------------------------------------------------ */ +/* Sanity: every target must exist (page slug, or product slug). */ +/* ------------------------------------------------------------------ */ +$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 hideonwebsite = 0 AND slug = ? LIMIT 1"); +$missing = 0; +foreach (array_unique(array_values($mapping)) as $target) { + if (str_starts_with($target, '/product/')) { + $productBySlug->execute([substr($target, strlen('/product/'))]); + if ((int)$productBySlug->fetchColumn() > 0) { continue; } + } else { + $pageBySlug->execute([$target]); + if ((int)$pageBySlug->fetchColumn() > 0) { continue; } + } + echo " !! Ziel fehlt: $target\n"; + $missing++; +} +if ($missing > 0) { + echo "$missing Ziele nicht gefunden - Mapping pruefen.\n"; + if ($apply) { exit("Abbruch.\n"); } +} + +/* ------------------------------------------------------------------ */ +/* 1. Rewrite hrefs in news bodytext/teaser */ +/* ------------------------------------------------------------------ */ +echo "1. Links in News-Texten ersetzen:\n"; +$findNews = $pdo->prepare("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)"); +$fixNews = $pdo->prepare( + "UPDATE tx_news_domain_model_news + SET bodytext = REPLACE(bodytext, ?, ?), teaser = REPLACE(teaser, ?, ?), tstamp = UNIX_TIMESTAMP() + WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)" +); +$totalHits = 0; +foreach ($mapping as $old => $new) { + // Both href variants: with and without trailing slash; plus the one + // broken absolute form (http://datapath-products/...). + $variants = ['href="' . $old . '/"', 'href="' . $old . '"', 'href="http:/' . $old . '/"']; + foreach ($variants as $needle) { + $like = '%' . $needle . '%'; + $findNews->execute([$like, $like]); + $count = (int)$findNews->fetchColumn(); + if ($count === 0) { continue; } + $totalHits += $count; + echo " $needle -> href=\"$new\" ($count Record(s))\n"; + if ($apply) { + $fixNews->execute([$needle, 'href="' . $new . '"', $needle, 'href="' . $new . '"', $like, $like]); + } + } +} +echo " Summe Record-Treffer: $totalHits\n"; + +/* ------------------------------------------------------------------ */ +/* 2. 301 redirects for the old paths */ +/* ------------------------------------------------------------------ */ +echo "2. Redirects anlegen:\n"; +$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)" +); +$newRedirects = 0; +foreach ($mapping as $old => $new) { + foreach ([$old, $old . '/'] as $source) { + $hasRedirect->execute([$source]); + if ((int)$hasRedirect->fetchColumn() > 0) { continue; } + $newRedirects++; + if ($apply) { + $insertRedirect->execute([$source, $new]); + } + } +} +echo " $newRedirects neue Redirects (mit + ohne Slash; bestehende unangetastet)\n"; + +echo $apply ? "FERTIG. Cache flushen.\n" : "DRY RUN beendet.\n"; diff --git a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php index 4c41edf..805af32 100644 --- a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php @@ -361,6 +361,22 @@ final class NewsJsonRenderer $pathSegment = (string)($news['path_segment'] ?? ''); $urls = $this->buildNewsUrls($news, $settings); + // Detail view only (contract 0083-BE): the SEO articles carry a + // "Frequently asked questions" section as bare

pairs plus inline + // product links. The Q&A pairs move into a structured `faq` band + // (accordion in the frontend), linked products/categories become + // `mentionedProducts` cards; the body keeps everything else. + $detailBody = null; + $faqBand = null; + $mentionedProducts = []; + if ($mode !== 'list') { + $detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html']; + $faqSplit = $this->extractFaqBand($detailBody); + $detailBody = $faqSplit['html']; + $faqBand = $faqSplit['faq']; + $mentionedProducts = $this->collectMentionedProducts($detailBody); + } + return [ 'uid' => $uid, 'title' => (string)($news['title'] ?? ''), @@ -380,7 +396,7 @@ final class NewsJsonRenderer // the page-level jsonLd @graph instead (PageJsonLdRenderer). 'bodytext' => $mode === 'list' ? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? ''))) - : \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'], + : (string)$detailBody, 'datetime' => (int)($news['datetime'] ?? 0), 'archive' => (int)($news['archive'] ?? 0), 'istopnews' => (bool)($news['istopnews'] ?? false), @@ -392,6 +408,8 @@ final class NewsJsonRenderer 'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid), 'related' => $this->resolveRelatedNews($uid), 'relatedLinks' => $this->resolveRelatedLinks($uid), + 'faq' => $faqBand, + 'mentionedProducts' => $mentionedProducts, ]; } @@ -595,6 +613,142 @@ final class NewsJsonRenderer return rtrim($cut, " .,;:") . '...'; } + /** + * Splits the trailing FAQ section out of an article body. + * + * Expected shape (the 17 SEO articles): an

/

whose text starts + * with "Frequently asked" or "FAQ", followed by alternating question + * paragraphs (plain text ending in "?") and answer paragraphs (may + * contain links). Fail-safe: anything that does not yield at least two + * clean Q&A pairs leaves the HTML untouched. + * + * @return array{html: string, faq: array{heading: string, items: array}|null} + */ + private function extractFaqBand(string $html): array + { + if (!preg_match('#]*>\s*((?:Frequently asked|FAQ)[^<]*)#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) { + return ['html' => $html, 'faq' => null]; + } + + $heading = trim((string)$headingMatch[2][0]); + $sectionStart = (int)$headingMatch[0][1]; + $cursor = $sectionStart + strlen((string)$headingMatch[0][0]); + $sectionEnd = $cursor; + + $items = []; + $question = null; + $answerParts = []; + + while (preg_match('#\G\s*]*>(.*?)

#s', $html, $paragraph, 0, $cursor)) { + $text = trim(strip_tags($paragraph[1])); + if ($text === '') { + $cursor += strlen($paragraph[0]); + $sectionEnd = $cursor; + continue; + } + if (str_ends_with($text, '?')) { + if ($question !== null && $answerParts !== []) { + $items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)]; + } + $question = $text; + $answerParts = []; + } elseif ($question !== null) { + $answerParts[] = '

' . trim($paragraph[1]) . '

'; + } else { + break; + } + $cursor += strlen($paragraph[0]); + $sectionEnd = $cursor; + } + + if ($question !== null && $answerParts !== []) { + $items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)]; + } + + if (count($items) < 2) { + return ['html' => $html, 'faq' => null]; + } + + $clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd); + + return [ + 'html' => trim($clean), + 'faq' => ['heading' => $heading, 'items' => $items], + ]; + } + + /** + * Cards for every product detail page (/product/) and product + * category page (/products/) linked inside an article body - + * order of first appearance, deduplicated, at most six. + * + * @return array> + */ + private function collectMentionedProducts(string $html): array + { + if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) { + return []; + } + + $cards = []; + foreach ($matches as $match) { + $href = (string)$match[1]; + if (isset($cards[$href]) || count($cards) >= 6) { + continue; + } + try { + if (!empty($match[2])) { + $qb = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('tx_vitec_domain_model_product'); + $row = $qb->select('uid', 'title', 'teaser', 'subtitle') + ->from('tx_vitec_domain_model_product') + ->where( + $qb->expr()->eq('deleted', 0), + $qb->expr()->eq('hidden', 0), + $qb->expr()->eq('hideonwebsite', 0), + $qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING)) + ) + ->setMaxResults(1)->executeQuery()->fetchAssociative(); + if (!is_array($row)) { + continue; + } + $cards[$href] = [ + 'type' => 'product', + 'title' => (string)($row['title'] ?? ''), + 'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''), + 'link' => $href, + 'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null, + ]; + } else { + $qb = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $row = $qb->select('uid', 'title', 'description', 'abstract') + ->from('pages') + ->where( + $qb->expr()->eq('deleted', 0), + $qb->expr()->eq('hidden', 0), + $qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING)) + ) + ->setMaxResults(1)->executeQuery()->fetchAssociative(); + if (!is_array($row)) { + continue; + } + $cards[$href] = [ + 'type' => 'category', + 'title' => (string)($row['title'] ?? ''), + 'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')), + 'link' => $href, + 'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null, + ]; + } + } catch (\Throwable $e) { + continue; + } + } + + return array_values($cards); + } + /** * Where a news item actually points. *